internal/thumbnail: Vorschau-Zustand je Datei-Revision als explizite State-Machine (pending/processing/ready/failed, revision_thumbnails) statt Ableitung aus Dateiendung. Content-Type wird ausschliesslich ueber Magic-Bytes bestimmt (net/http.DetectContentType gegen die entschluesselten Bytes) - der in "Bekannte Fehler vermeiden" beschriebene Fehler eines Vorgaengerprojekts (fragile Erkennung ueber Dateiendung) wird damit vermieden. PDF-Rasterung ueber pdftoppm (Poppler), Bild-Thumbnails ueber einen minimalen, abhaengigkeitsfreien Nearest-Neighbor-Resizer auf Basis der Standardbibliothek. Original wird ueber FDN-09 (dmscrypto) entschluesselt, Thumbnail wird ebenfalls verschluesselt im Objekt-Storage abgelegt - gleiches Schutzniveau wie das Original. upload.Service bekommt SetThumbnailEnqueuer (neue, optionale Methode - bestehende Aufrufer/Tests unveraendert funktionsfaehig) und reiht nach erfolgreichem Complete() die Vorschau-Erzeugung ein; ein Fehlschlag dabei laesst den bereits abgeschlossenen Upload nicht ruecktwirkend fehlschlagen. Pruefungen: 1. Beschaedigte PDF-Datei -> Fehlerstatus statt Absturz, blockiert andere Jobs nicht (echter pdftoppm-Fehlschlag, echte SKIP-LOCKED-Job-Queue) 2. Durchsatztest: 500 echte PDFs, alle erfolgreich, ~15s Gesamtlaufzeit 3. Cache-Invalidierung bei neuer Revision: GetCurrentForDocument loest nachweislich auf die neue statt die alte Revision auf Regressionsfrei: 13 bestehende upload-/jobqueue-Tests unveraendert PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
139 lines
5.0 KiB
Go
139 lines
5.0 KiB
Go
// Package thumbnail implementiert DOC-04: asynchrone Erzeugung von
|
|
// Seiten-Vorschauen/Thumbnails nach dem Upload, mit Cache (ein Thumbnail
|
|
// je Datei-Revision) und Wiederaufbau bei neuer Revision (jede Revision
|
|
// hat ihre EIGENE Zeile — eine neue Revision bekommt automatisch einen
|
|
// eigenen, frisch erzeugten Thumbnail-Zustand statt eines wiederverwendeten
|
|
// alten). Zustand ist eine explizite State-Machine (pending/processing/
|
|
// ready/failed) statt impliziter Ableitung aus Dateiendung/Vorhandensein
|
|
// einer Datei (siehe "Bekannte Fehler vermeiden" im Ticket).
|
|
package thumbnail
|
|
|
|
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 = "thumbnail.generate"
|
|
|
|
const (
|
|
StatusPending = "pending"
|
|
StatusProcessing = "processing"
|
|
StatusReady = "ready"
|
|
StatusFailed = "failed"
|
|
)
|
|
|
|
// ErrNotFound wird geliefert, wenn fuer eine Revision/ein Dokument kein
|
|
// Thumbnail-Zustand existiert.
|
|
var ErrNotFound = errors.New("thumbnail: kein zustand gefunden")
|
|
|
|
// Thumbnail ist der Vorschau-Zustand EINER Datei-Revision.
|
|
type Thumbnail struct {
|
|
RevisionID string
|
|
Status string
|
|
StorageKey *string
|
|
MimeType *string
|
|
WrappedDEK []byte
|
|
Error *string
|
|
}
|
|
|
|
type jobPayload struct {
|
|
DocumentID string `json:"document_id"`
|
|
RevisionID string `json:"revision_id"`
|
|
}
|
|
|
|
// Enqueue legt den anfänglichen "pending"-Zustand für eine Revision an und
|
|
// reiht den Erzeugungs-Job ein (Akzeptanzkriterium 1). Wird von wem auch
|
|
// immer eine neue Revision anlegt aufgerufen (z. B. internal/upload nach
|
|
// erfolgreichem Complete) — idempotent: ein erneuter Aufruf für dieselbe
|
|
// revisionID ändert nichts am bereits bestehenden Zustand.
|
|
func Enqueue(ctx context.Context, pool *pgxpool.Pool, queue *jobqueue.Queue, documentID, revisionID string) error {
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO revision_thumbnails (revision_id, status)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (revision_id) DO NOTHING
|
|
`, revisionID, StatusPending); err != nil {
|
|
return fmt.Errorf("thumbnail: pending-zustand anlegen: %w", err)
|
|
}
|
|
if _, err := queue.Enqueue(ctx, JobType, jobPayload{DocumentID: documentID, RevisionID: revisionID}, jobqueue.EnqueueOptions{
|
|
IdempotencyKey: "thumbnail:" + revisionID,
|
|
}); err != nil {
|
|
return fmt.Errorf("thumbnail: job einreihen: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Enqueuer adaptiert Enqueue auf die von internal/upload erwartete
|
|
// ThumbnailEnqueuer-Schnittstelle (upload.Service.SetThumbnailEnqueuer).
|
|
type Enqueuer struct {
|
|
Pool *pgxpool.Pool
|
|
Queue *jobqueue.Queue
|
|
}
|
|
|
|
func (e *Enqueuer) Enqueue(ctx context.Context, documentID, revisionID string) error {
|
|
return Enqueue(ctx, e.Pool, e.Queue, documentID, revisionID)
|
|
}
|
|
|
|
// GetForRevision liefert den aktuellen Vorschau-Zustand einer bestimmten
|
|
// Revision.
|
|
func GetForRevision(ctx context.Context, pool *pgxpool.Pool, revisionID string) (*Thumbnail, error) {
|
|
var t Thumbnail
|
|
t.RevisionID = revisionID
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT status, storage_key, mime_type, wrapped_dek, error
|
|
FROM revision_thumbnails WHERE revision_id = $1
|
|
`, revisionID).Scan(&t.Status, &t.StorageKey, &t.MimeType, &t.WrappedDEK, &t.Error)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("thumbnail: zustand lesen: %w", err)
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// GetCurrentForDocument löst über documents.current_revision_id die
|
|
// AKTUELL gültige Revision auf und liefert deren Vorschau-Zustand — der
|
|
// Aufrufer bekommt dadurch nie ein Thumbnail einer veralteten Revision
|
|
// (Akzeptanzkriterium 2: Cache-Invalidierung bei neuer Revision).
|
|
func GetCurrentForDocument(ctx context.Context, pool *pgxpool.Pool, documentID string) (*Thumbnail, error) {
|
|
var currentRevisionID *string
|
|
if err := pool.QueryRow(ctx, `SELECT current_revision_id FROM documents WHERE id = $1`, documentID).Scan(¤tRevisionID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("thumbnail: dokument lesen: %w", err)
|
|
}
|
|
if currentRevisionID == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
return GetForRevision(ctx, pool, *currentRevisionID)
|
|
}
|
|
|
|
func markProcessing(ctx context.Context, pool *pgxpool.Pool, revisionID string) error {
|
|
_, err := pool.Exec(ctx, `UPDATE revision_thumbnails SET status = $2, updated_at = now() WHERE revision_id = $1`, revisionID, StatusProcessing)
|
|
return err
|
|
}
|
|
|
|
func markFailed(ctx context.Context, pool *pgxpool.Pool, revisionID string, cause error) error {
|
|
msg := cause.Error()
|
|
_, err := pool.Exec(ctx, `
|
|
UPDATE revision_thumbnails SET status = $2, error = $3, updated_at = now() WHERE revision_id = $1
|
|
`, revisionID, StatusFailed, msg)
|
|
return err
|
|
}
|
|
|
|
func markReady(ctx context.Context, pool *pgxpool.Pool, revisionID, storageKey, mimeType string, wrappedDEK []byte) error {
|
|
_, err := pool.Exec(ctx, `
|
|
UPDATE revision_thumbnails
|
|
SET status = $2, storage_key = $3, mime_type = $4, wrapped_dek = $5, error = NULL, updated_at = now()
|
|
WHERE revision_id = $1
|
|
`, revisionID, StatusReady, storageKey, mimeType, wrappedDEK)
|
|
return err
|
|
}
|