Files
nexarch/dms/internal/thumbnail/thumbnail.go
T
patrickandClaude Sonnet 5 08584b0c5f DOC-04: Vorschau- & Thumbnail-Pipeline
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>
2026-09-17 19:42:30 +02:00

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(&currentRevisionID); 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
}