Enqueue prueft konfigurierbare Obergrenzen (Dokumentanzahl, Gesamtgroesse) VOR dem Anlegen des Auftrags/Einreihen des Jobs und lehnt Ueberschreitungen mit konkreter Fehlermeldung ab, statt den Job-Worker-Pool zu belasten. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
5.5 KiB
Go
149 lines
5.5 KiB
Go
// Package export implementiert DOC-08: Auswahl mehrerer Dokumente als
|
|
// Hintergrund-Job zu einem ZIP-Archiv bündeln, inklusive Metadaten-Beilage.
|
|
// State-Machine (pending/processing/ready/failed) analog zu
|
|
// internal/thumbnail (DOC-04) und internal/malwarescan (DOC-17).
|
|
//
|
|
// DOC-18: harte, konfigurierbare Obergrenzen für Gesamtgröße und
|
|
// Dokumentanzahl je Bündelungs-Anfrage — geprüft VOR dem Anlegen des
|
|
// Auftrags/Einreihen des Jobs, damit eine überdimensionierte Anfrage nie
|
|
// den gemeinsamen Job-Worker-Pool erreicht (Threat-Model-Finding #9).
|
|
package export
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
|
|
)
|
|
|
|
const JobType = "export.zip"
|
|
|
|
const (
|
|
StatusPending = "pending"
|
|
StatusProcessing = "processing"
|
|
StatusReady = "ready"
|
|
StatusFailed = "failed"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("export: nicht gefunden")
|
|
|
|
// ErrTooManyDocuments/ErrTotalSizeTooLarge werden von Enqueue geliefert,
|
|
// wenn eine Anfrage die konfigurierte Obergrenze überschreitet (DOC-18
|
|
// Akzeptanzkriterium 1) — als eigener Fehlertyp mit den tatsächlichen
|
|
// Zahlen, damit der Aufrufer eine klare, konkrete Fehlermeldung anzeigen
|
|
// kann statt nur "abgelehnt".
|
|
type ErrTooManyDocuments struct {
|
|
Requested, MaxAllowed int
|
|
}
|
|
|
|
func (e *ErrTooManyDocuments) Error() string {
|
|
return fmt.Sprintf("export: %d dokumente angefragt, maximal %d je anfrage erlaubt", e.Requested, e.MaxAllowed)
|
|
}
|
|
|
|
type ErrTotalSizeTooLarge struct {
|
|
RequestedBytes, MaxAllowedBytes int64
|
|
}
|
|
|
|
func (e *ErrTotalSizeTooLarge) Error() string {
|
|
return fmt.Sprintf("export: gesamtgroesse %d bytes angefragt, maximal %d bytes je anfrage erlaubt", e.RequestedBytes, e.MaxAllowedBytes)
|
|
}
|
|
|
|
// Limits sind die DOC-18-Obergrenzen — vom Aufrufer (cmd/app, aus
|
|
// Umgebungsvariablen) befüllt, nicht hart im Code verankert
|
|
// (Akzeptanzkriterium 3). Ein Feld mit Wert 0 bedeutet "keine Grenze"
|
|
// (z. B. für Tests, die die Begrenzung selbst nicht prüfen wollen).
|
|
type Limits struct {
|
|
MaxDocuments int
|
|
MaxTotalSizeBytes int64
|
|
}
|
|
|
|
// Export ist der Zustand EINES Massenexport-Auftrags.
|
|
type Export struct {
|
|
ID string
|
|
RequestedBy string
|
|
DocumentIDs []string
|
|
Status string
|
|
StorageKey *string
|
|
SkippedIDs []string
|
|
Error *string
|
|
}
|
|
|
|
type jobPayload struct {
|
|
ExportID string `json:"export_id"`
|
|
}
|
|
|
|
// Enqueue prüft die DOC-18-Obergrenzen (Akzeptanzkriterium 1: Ablehnung
|
|
// VOR Jobstart, mit klarer Fehlermeldung) und legt danach den Export-
|
|
// Auftrag im "pending"-Zustand an und reiht den Verarbeitungs-Job ein
|
|
// (Akzeptanzkriterium 1 aus DOC-08: läuft als Job, blockiert die
|
|
// Oberfläche nicht). Liefert die Export-ID, über die der Fortschritt
|
|
// abgefragt werden kann.
|
|
func Enqueue(ctx context.Context, pool *pgxpool.Pool, queue *jobqueue.Queue, requestedBy string, documentIDs []string, limits Limits) (string, error) {
|
|
if limits.MaxDocuments > 0 && len(documentIDs) > limits.MaxDocuments {
|
|
return "", &ErrTooManyDocuments{Requested: len(documentIDs), MaxAllowed: limits.MaxDocuments}
|
|
}
|
|
if limits.MaxTotalSizeBytes > 0 {
|
|
var totalSize int64
|
|
if err := pool.QueryRow(ctx, `
|
|
SELECT COALESCE(SUM(fr.size_bytes), 0)
|
|
FROM documents d JOIN file_revisions fr ON fr.id = d.current_revision_id
|
|
WHERE d.id = ANY($1)
|
|
`, documentIDs).Scan(&totalSize); err != nil {
|
|
return "", fmt.Errorf("export: gesamtgroesse ermitteln: %w", err)
|
|
}
|
|
if totalSize > limits.MaxTotalSizeBytes {
|
|
return "", &ErrTotalSizeTooLarge{RequestedBytes: totalSize, MaxAllowedBytes: limits.MaxTotalSizeBytes}
|
|
}
|
|
}
|
|
|
|
var exportID string
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO document_exports (requested_by, document_ids, status) VALUES ($1, $2, $3) RETURNING id
|
|
`, requestedBy, documentIDs, StatusPending).Scan(&exportID); err != nil {
|
|
return "", fmt.Errorf("export: auftrag anlegen: %w", err)
|
|
}
|
|
if _, err := queue.Enqueue(ctx, JobType, jobPayload{ExportID: exportID}, jobqueue.EnqueueOptions{
|
|
IdempotencyKey: "export:" + exportID,
|
|
}); err != nil {
|
|
return "", fmt.Errorf("export: job einreihen: %w", err)
|
|
}
|
|
return exportID, nil
|
|
}
|
|
|
|
// Get liefert den aktuellen Zustand eines Export-Auftrags.
|
|
func Get(ctx context.Context, pool *pgxpool.Pool, exportID string) (Export, error) {
|
|
var e Export
|
|
e.ID = exportID
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT requested_by, document_ids, status, storage_key, skipped_ids, error FROM document_exports WHERE id = $1
|
|
`, exportID).Scan(&e.RequestedBy, &e.DocumentIDs, &e.Status, &e.StorageKey, &e.SkippedIDs, &e.Error)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Export{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Export{}, fmt.Errorf("export: auftrag lesen: %w", err)
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
func markProcessing(ctx context.Context, pool *pgxpool.Pool, exportID string) error {
|
|
_, err := pool.Exec(ctx, `UPDATE document_exports SET status = $2, updated_at = now() WHERE id = $1`, exportID, StatusProcessing)
|
|
return err
|
|
}
|
|
|
|
func markFailed(ctx context.Context, pool *pgxpool.Pool, exportID string, cause error) error {
|
|
_, err := pool.Exec(ctx, `UPDATE document_exports SET status = $2, error = $3, updated_at = now() WHERE id = $1`, exportID, StatusFailed, cause.Error())
|
|
return err
|
|
}
|
|
|
|
func markReady(ctx context.Context, pool *pgxpool.Pool, exportID, storageKey string, skippedIDs []string) error {
|
|
_, err := pool.Exec(ctx, `
|
|
UPDATE document_exports SET status = $2, storage_key = $3, skipped_ids = $4, error = NULL, updated_at = now() WHERE id = $1
|
|
`, exportID, StatusReady, storageKey, skippedIDs)
|
|
return err
|
|
}
|