POC.2: port cover.generate to go-workflows workflow+activity (bookshelf-vguw.2) #327
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-vguw.2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
GenerateCoverWorkflow(bookID)+CoverActivities.Generateininternal/wfengine; activity body is a zero-reimplementation wrapper over the existingcover.BuildGenerateFunc(same pipeline as the pool handler)workflow.ActivityOptions.RetryOptionsPOST /admin/workflows/_poc/cover/{bookID}(AdminRequired, explicitPOSTmethod — avoids theGET /conflict that bit POC.1)Engine.New/newWithFactoryextended to acceptgenerateCover func(context.Context, int64) error; cover workflow + activity registered on the worker only when non-nilapp.goandcmd/pergamum/worker.goviacover.BuildGenerateFunc(appwire.Deps{...})Test plan
Local verification (requires
PERGAMUM_WFENGINE_ENABLED=true)make dev/admin/workflows/— the instance should appear asCompleteddata/images/1/cover.jpgandthumbnail.jpgexistRetry durability check (manual)
DataDir) so the activity returns an errorWorker mid-run kill (durability)
docker compose kill worker)CI
Standard CI runs
make test+make coverage(100% gate). The enabled-path integration test (engine_integration_test.go) verifies the cover route with a stubgenerateCoveragainst a real MySQL (testcontainers), catching the class of bugs that hit POC.1.Part of POC epic bookshelf-vguw; base is poc/go-workflows, do not merge to main.
Closes bead bookshelf-vguw.2 on merge.
- Add GenerateCoverWorkflow + CoverActivities to internal/wfengine - Wire activity body via BuildGenerateFunc in cover/wire.go — reuses the existing cover.generate pipeline with zero re-implementation - Configure activity retry policy to mirror pool policy: 5 attempts, 30s first interval, 2x backoff, 5min max - Add POST /admin/workflows/_poc/cover/{bookID} trigger route (AdminRequired) - Update Engine.New/newWithFactory to accept generateCover func dep - Register cover workflow+activity on the worker when dep is non-nil - Wire generateCover in both app.go and cmd/pergamum/worker.go - 100% coverage: unit tests via workflow tester + curried stubs; enabled-path integration test with real MySQL + stub generateCover Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Security Review — bd-bookshelf-vguw.2 (POC.2: cover workflow)
Scope
Reviewed
git diff origin/poc/go-workflows...origin/bd-bookshelf-vguw.2against.claude/rules/review-standard.md. CI is green; tests were not re-run.Findings
No blockers. No majors. Three minors:
[MINOR] internal/wfengine/engine.go:180 — bookID logged at Info without a guard on non-existent book
logger.Info("wfengine: cover workflow started", "instance_id", instanceID, "book_id", bookID)fires before the activity runs and confirms the book exists. An admin can deduce that a workflow was enqueued for any int64, including IDs that don't exist in the DB (the activity will simply return an error later). For a POC behindAdminRequiredthis is acceptable — book IDs are not secrets and admins already have full DB access — but worth noting for when this pattern graduates to production scale.Suggested fix: no immediate action needed for the POC; log at Info is correct. When graduating, consider logging only after the first activity heartbeat confirms book existence.
[MINOR] internal/wfengine/cover_handler.go:40 — json.Encode error discarded silently
_ = json.NewEncoder(w).Encode(result)discards the encode error. Afterw.WriteHeader(http.StatusAccepted)the status is committed and a partial write can't be recovered, so discarding is pragmatically harmless. However a log line on error would surface network-level issues silently:[MINOR] internal/wfengine/engine.go (no line) — no per-bookID deduplication / queue-depth guard
An admin can POST
/admin/workflows/_poc/cover/{bookID}in a tight loop, enqueuing unbounded workflow instances for the same bookID. Each call generates a fresh UUID instance ID so there is no idempotency guard. For a POC feature gated onPERGAMUM_WFENGINE_ENABLEDand restricted to admins, spamming the queue is a self-inflicted problem and not a security issue — but it is a resource-exhaustion footgun that must be addressed before the pattern reaches any broader surface. File a follow-up bead when this graduates from POC.Security checklist results
RegisterRouteswraps both Ping and Cover routes withadminRequired(...).AdminRequiredininternal/users/middleware.gochecks JWT claims +IsAdminflag; unauthenticated requests get 401/redirect, non-admin get 403. ✓strconv.ParseInt(bookIDStr, 10, 64)+bookID <= 0guard before any further use. The int64 value is passed to sqlc-generated queries (parameterised) and intofmt.Sprintf({"book_id":%d}, bookID)— no string interpolation into SQL; no injection risk. ✓GetPrimaryBookFileandGetBookLibraryPath— both keyed bybook_idwith no per-user data read or written. Nouser_book_progressor other per-user tables are touched. ✓maskDSNis present inbackend.go; the only log additions in this diff are"book_id"and"instance_id"— no credentials. ✓http.Error(w, "failed to start cover workflow", ...)is used; internal error is logged server-side only. The test atcover_handler_test.go:268explicitly asserts the internal error string is not in the response body. ✓_ = json.NewEncoder(w).Encode(result)discard is post-commit (minor, above). All DB and workflow errors propagate up and result in logged 500s. ✓REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Code Review — POC.2 cover.generate workflow (bookshelf-vguw.2)
Phase 0: DEMO Verification
DEMO requires a running dev stack with PERGAMUM_WFENGINE_ENABLED=true and a seeded book. PARTIAL DEMO with valid reason (local Docker + workflow engine required). Noted for human verification; proceeding to Phase 1/2.
Phase 1: Spec Compliance
All spec items met: GenerateCoverWorkflow+CoverActivities in cover_workflow.go, MaxAttempts=5 retry policy, POST trigger endpoint behind AdminRequired, BuildGenerateFunc reuses buildGenerateHandler, old pool handler intact, 100% coverage gate per CI.
Phase 2: Code Quality
[MINOR] internal/wfengine/cover_workflow.go:20 — Retry policy comment misrepresents backoff sequence. The comment says "5 attempts with exponential back-off starting at 30 s" but go-workflows retries.go line 87 computes FirstRetryInterval * BackoffCoefficient^attempt (where attempt is post-incremented before the wait), so actual delays are 60 s, 120 s, 240 s (capped 300 s), 300 s. The pool actual sequence (workerpolicy/policy.go) is [0, 30 s, 60 s, 120 s, 300 s] — first pool retry is immediate; first activity retry is 60 s. MaxAttempts=5 and the 5-min cap are correct. Suggested fix: update comment to state actual computed sequence or align delays to match the pool exactly.
[MINOR] internal/wfengine/cover_handler_test.go:109 — Two Expect calls in one It block. It("returns a generic client message (not the internal error detail)") calls Expect twice (ContainSubstring + NotTo ContainSubstring). Project convention requires one Expect per It. Split into two It blocks.
[MINOR] internal/wfengine/engine_integration_test.go:251 — Four Expect calls in one It block ("serves POST /admin/workflows/_poc/cover/{bookID} with 202 and an instance_id"). Pre-existing pattern carried from the ping route test, but still violates one-Expect-per-It.
Summary
Workflow determinism: CLEAN. new(CoverActivities) in workflow body only supplies fn.Name reflection for activity lookup; the registered instance (with real generate closure) executes. No I/O, no time.Now, no randomness in workflow body.
bookID validation: ParseInt + <= 0 guard before any workflow call. No panic on bad input.
Reuse wrapper: BuildGenerateFunc delegates to buildGenerateHandler; no-op callbacks do not drop errors. Error propagation intact.
Wiring: generateCover=nil path skips registration cleanly; StartCoverWorkflow returns typed error when not registered. Route is POST method-specific (avoids GET-/ conflict). AdminRequired applied.
REVIEW VERDICT: 0 blocker, 0 major, 3 minor