CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
285 lines
11 KiB
Go
285 lines
11 KiB
Go
// Package workerruntime verdrahtet die asynchronen Jobtypen des DMS (FDN-17):
|
|
// Vorschau, PDF-Konvertierung, Virenscan, Massenexport, E-Rechnungen und Texterkennung laufen
|
|
// ueber EINEN Dispatcher auf der Job-Queue (FDN-04). Jobtypen, deren Werkzeug
|
|
// oder Konfiguration fehlt, werden nicht registriert und damit nicht abgeholt;
|
|
// der Grund steht in Runtime.Notices.
|
|
package workerruntime
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/downloadapi"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/export"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/integrity"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/mailimport"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/malwarescan"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/ocr"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/pdfaconform"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/pdfconvert"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/plaintext"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchsync"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/thumbnail"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/trigger"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/upload"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/workflowevent"
|
|
)
|
|
|
|
// Deps sind die gemeinsamen Abhaengigkeiten aller Handler.
|
|
type Deps struct {
|
|
Pool *pgxpool.Pool
|
|
Storage *storage.Service
|
|
Crypto *dmscrypto.Service
|
|
TenantSlug string
|
|
Queue *jobqueue.Queue
|
|
|
|
// Scanner ist der Virenscanner (clamd); nil = malwarescan.scan wird nicht abgeholt.
|
|
Scanner malwarescan.Scanner
|
|
// LookPath ist ueberschreibbar fuer Tests (Default exec.LookPath).
|
|
LookPath func(string) (string, error)
|
|
// OCR passt die Engine an (Standard: DefaultEngine).
|
|
OCR *ocr.Processor
|
|
// SearchIndex ist die Manticore-Verbindung; nil = searchindex.sync wird nicht abgeholt.
|
|
SearchIndex *sql.DB
|
|
// PlaintextMaxBytes/PlaintextMaxChars begrenzen den Klartext-Volltext (0 = Standard).
|
|
PlaintextMaxBytes int64
|
|
PlaintextMaxChars int
|
|
// MailImport ist der IMAP-Importer (INT-03); nil = mailimport.poll wird nicht abgeholt.
|
|
MailImport *mailimport.Importer
|
|
// EInvoiceValidator ist der KoSIT-Sidecar; nil = Ergebnis not_checked.
|
|
EInvoiceValidator *einvoice.Validator
|
|
// PDFAValidator ist der veraPDF-Sidecar (SRC-14); nil = Ergebnis not_checked.
|
|
PDFAValidator *pdfaconform.Validator
|
|
// Integrity ist die Objektpruefung (FDN-23); nil = integrity.verify wird nicht abgeholt.
|
|
Integrity *integrity.Config
|
|
// WorkflowEvent ist die INT-13-Anbindung (leere BaseURL = abgeschaltet).
|
|
WorkflowEvent workflowevent.Config
|
|
}
|
|
|
|
func (d Deps) lookPath(name string) error {
|
|
lp := d.LookPath
|
|
if lp == nil {
|
|
lp = exec.LookPath
|
|
}
|
|
_, err := lp(name)
|
|
return err
|
|
}
|
|
|
|
// Runtime haelt die registrierten Handler und die Meldungen zu nicht registrierten Jobtypen.
|
|
type Runtime struct {
|
|
Handlers map[string]jobqueue.Handler
|
|
Notices []string
|
|
// Limits sind jobtypspezifische Ressourcengrenzen (FDN-11), die Run an die Worker weitergibt.
|
|
Limits map[string]jobqueue.Limits
|
|
// Middleware umhuellt den Dispatcher (z. B. Fehlerverfolgung); optional.
|
|
Middleware func(jobqueue.Handler) jobqueue.Handler
|
|
queue *jobqueue.Queue
|
|
}
|
|
|
|
// New registriert alle Jobtypen, deren Voraussetzungen erfuellt sind.
|
|
func New(d Deps) *Runtime {
|
|
rt := &Runtime{Handlers: map[string]jobqueue.Handler{}, Limits: map[string]jobqueue.Limits{}, queue: d.Queue}
|
|
skip := func(jobType, why string) {
|
|
rt.Notices = append(rt.Notices, fmt.Sprintf("jobtyp %s wird nicht abgeholt: %s", jobType, why))
|
|
}
|
|
triggers := &trigger.Adapter{Pool: d.Pool, Vault: trigger.CryptoVault{Crypto: d.Crypto}, TenantSlug: d.TenantSlug}
|
|
|
|
if err := d.lookPath("pdftoppm"); err != nil {
|
|
skip(thumbnail.JobType, "pdftoppm nicht gefunden")
|
|
} else {
|
|
g := &thumbnail.Generator{Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug}
|
|
rt.Handlers[thumbnail.JobType] = g.Handler
|
|
}
|
|
|
|
if err := d.lookPath("soffice"); err != nil {
|
|
skip(pdfconvert.JobType, "soffice (LibreOffice) nicht gefunden")
|
|
} else {
|
|
g := &pdfconvert.Generator{Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug}
|
|
rt.Handlers[pdfconvert.JobType] = g.Handler
|
|
}
|
|
|
|
if d.Scanner == nil {
|
|
skip(malwarescan.JobType, "kein Virenscanner (clamd) konfiguriert; hochgeladene Dokumente bleiben unsichtbar, bis er laeuft")
|
|
} else {
|
|
p := &malwarescan.Processor{Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug, Scanner: d.Scanner}
|
|
rt.Handlers[malwarescan.JobType] = p.Handler
|
|
}
|
|
|
|
p := &export.Processor{Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug, Permissions: &downloadapi.DocumentOwnerChecker{Pool: d.Pool}}
|
|
rt.Handlers[export.JobType] = p.Handler
|
|
|
|
if d.SearchIndex == nil {
|
|
skip(searchsync.JobType, "kein Suchindex (Manticore) konfiguriert; Dokumente sind nicht ueber die Suche auffindbar")
|
|
} else {
|
|
sy := &searchsync.Syncer{Pool: d.Pool, Index: d.SearchIndex, Text: searchsync.MultiText{ocr.TextStore{Pool: d.Pool, Crypto: d.Crypto, TenantSlug: d.TenantSlug}, einvoice.TextStore{Pool: d.Pool, Crypto: d.Crypto, TenantSlug: d.TenantSlug}, plaintext.TextStore{Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug, MaxBytes: d.PlaintextMaxBytes, MaxChars: d.PlaintextMaxChars}}}
|
|
rt.Handlers[searchsync.JobType] = sy.Handler
|
|
}
|
|
|
|
if d.MailImport == nil {
|
|
skip(mailimport.JobType, "kein Mail-Importer (Staging-Verzeichnis) konfiguriert; Postfaecher werden nicht abgerufen")
|
|
} else {
|
|
rt.Handlers[mailimport.JobType] = d.MailImport.Handler
|
|
}
|
|
|
|
if d.Integrity == nil {
|
|
skip(integrity.JobType, "keine Integritaetspruefung konfiguriert (NEXARCH_DMS_WORKER_INTEGRITY_INTERVAL_SECONDS)")
|
|
} else {
|
|
iv := &integrity.Verifier{Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug, Config: *d.Integrity}
|
|
rt.Handlers[integrity.JobType] = iv.Handler
|
|
rt.Limits[integrity.JobType] = d.Integrity.JobLimits()
|
|
}
|
|
|
|
ei := &einvoice.Processor{Validator: d.EInvoiceValidator, Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug}
|
|
if _, ok := rt.Handlers[searchsync.JobType]; ok {
|
|
ei.OnRead = func(ctx context.Context, documentID, _ string) error {
|
|
return searchsync.Enqueue(ctx, d.Queue, documentID)
|
|
}
|
|
}
|
|
// INT-20: document.einvoice_checked nach jeder Konformitaetspruefung (Erst- und Neupruefung).
|
|
ei.OnConformanceChecked = func(ctx context.Context, documentID, _ string) error {
|
|
return triggers.Fire(ctx, trigger.EventEInvoiceChecked, documentID)
|
|
}
|
|
rt.Handlers[einvoice.JobType] = ei.Handler
|
|
rt.Handlers[einvoice.JobTypeValidate] = ei.HandlerValidate
|
|
|
|
missing := ""
|
|
for _, tool := range []string{"tesseract", "pdftoppm", "pdftotext", "pdfinfo"} {
|
|
if err := d.lookPath(tool); err != nil {
|
|
missing = tool
|
|
break
|
|
}
|
|
}
|
|
if missing != "" {
|
|
for _, jt := range ocr.JobTypes {
|
|
skip(jt, missing+" nicht gefunden")
|
|
}
|
|
} else {
|
|
proc := d.OCR
|
|
if proc == nil {
|
|
proc = &ocr.Processor{Engine: ocr.DefaultEngine()}
|
|
}
|
|
proc.Pool, proc.Storage, proc.Crypto, proc.TenantSlug, proc.Queue = d.Pool, d.Storage, d.Crypto, d.TenantSlug, d.Queue
|
|
_, syncIndex := rt.Handlers[searchsync.JobType]
|
|
if proc.OnText == nil {
|
|
proc.OnText = func(ctx context.Context, documentID, _ string) error {
|
|
if syncIndex {
|
|
if err := searchsync.Enqueue(ctx, d.Queue, documentID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// INT-20: document.ocr_completed nach Vorliegen des Volltexts.
|
|
return triggers.Fire(ctx, trigger.EventOCRCompleted, documentID)
|
|
}
|
|
}
|
|
if proc.OnPDFACreated == nil {
|
|
proc.OnPDFACreated = func(ctx context.Context, documentID, revisionID string) error {
|
|
return pdfaconform.Enqueue(ctx, d.Pool, d.Queue, documentID, revisionID)
|
|
}
|
|
}
|
|
for _, jt := range ocr.JobTypes {
|
|
rt.Handlers[jt] = proc.Handle
|
|
}
|
|
}
|
|
pc := &pdfaconform.Processor{Validator: d.PDFAValidator, Pool: d.Pool, Storage: d.Storage, Crypto: d.Crypto, TenantSlug: d.TenantSlug}
|
|
rt.Handlers[pdfaconform.JobType] = pc.Handler
|
|
|
|
if !d.WorkflowEvent.Enabled() {
|
|
skip(workflowevent.JobType, "keine Workflow-Anbindung konfiguriert (NEXARCH_DMS_APP_WORKFLOW_URL)")
|
|
} else {
|
|
rt.Handlers[workflowevent.JobType] = workflowevent.Handler(d.WorkflowEvent)
|
|
}
|
|
return rt
|
|
}
|
|
|
|
// JobTypes liefert die registrierten Jobtypen, sortiert.
|
|
func (r *Runtime) JobTypes() []string {
|
|
out := make([]string, 0, len(r.Handlers))
|
|
for jt := range r.Handlers {
|
|
out = append(out, jt)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Dispatch ist der eine jobqueue.Handler: er leitet nach Jobtyp weiter und
|
|
// meldet einen unbekannten Typ als Fehler (Wiederholung, danach Dead-Letter).
|
|
func (r *Runtime) Dispatch(ctx context.Context, job *jobqueue.Job) error {
|
|
h, ok := r.Handlers[job.JobType]
|
|
if !ok {
|
|
return fmt.Errorf("workerruntime: kein handler fuer jobtyp %q", job.JobType)
|
|
}
|
|
return h(ctx, job)
|
|
}
|
|
|
|
// Run startet n parallele Worker auf derselben Queue und blockiert bis ctx endet.
|
|
func (r *Runtime) Run(ctx context.Context, n int, workerPrefix string, poll time.Duration) {
|
|
if n <= 0 {
|
|
n = 1
|
|
}
|
|
types := r.JobTypes()
|
|
if len(types) == 0 {
|
|
log.Print("workerruntime: keine jobtypen registriert, nichts zu tun")
|
|
<-ctx.Done()
|
|
return
|
|
}
|
|
handler := jobqueue.Handler(r.Dispatch)
|
|
if r.Middleware != nil {
|
|
handler = r.Middleware(handler)
|
|
}
|
|
var opts []jobqueue.WorkerOption
|
|
for jt, l := range r.Limits {
|
|
opts = append(opts, jobqueue.WithJobTypeLimits(jt, l))
|
|
}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < n; i++ {
|
|
w := jobqueue.NewWorker(r.queue, fmt.Sprintf("%s-%d", workerPrefix, i+1), types, poll, handler, opts...)
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); w.Run(ctx) }()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// AttachEnqueuers versieht den Upload-Dienst mit den Einreihern fuer Vorschau,
|
|
// Virenscan, Texterkennung und Suchindexierung (nur fuer Jobtypen, die der
|
|
// Worker tatsaechlich abholt, sonst blieben sie ewig pending).
|
|
func AttachEnqueuers(svc *upload.Service, pool *pgxpool.Pool, queue *jobqueue.Queue, handled []string) {
|
|
has := map[string]bool{}
|
|
for _, jt := range handled {
|
|
has[jt] = true
|
|
}
|
|
if has[thumbnail.JobType] {
|
|
svc.SetThumbnailEnqueuer(&thumbnail.Enqueuer{Pool: pool, Queue: queue})
|
|
}
|
|
if has[malwarescan.JobType] {
|
|
svc.SetMalwareScanEnqueuer(&malwarescan.Enqueuer{Pool: pool, Queue: queue})
|
|
}
|
|
if has[ocr.JobPlan] {
|
|
svc.SetOCREnqueuer(&ocr.Enqueuer{Pool: pool, Queue: queue})
|
|
}
|
|
if has[einvoice.JobType] {
|
|
svc.SetEInvoiceEnqueuer(&einvoice.Enqueuer{Pool: pool, Queue: queue})
|
|
}
|
|
if has[searchsync.JobType] {
|
|
svc.SetSearchIndexEnqueuer(&searchsync.Enqueuer{Queue: queue})
|
|
}
|
|
}
|
|
|
|
// HostName liefert einen Worker-Praefix aus Host und Prozess.
|
|
func HostName() string {
|
|
h, _ := os.Hostname()
|
|
return fmt.Sprintf("%s-%d", h, os.Getpid())
|
|
}
|