1. What it is
Lessons need illustrations, and nobody wants to brief a designer for every safety module. This worker takes an image description from authoring-service-v2, renders it with Google Gemini, stores the result through asset-manager, and tells authoring which block the finished picture belongs to. It runs the same job two ways: immediately, while an admin watches, or accumulated into a Gemini Batch API job that costs about half as much and may take hours.
Deployment hazard: this worker rewrites its own stream at boot
At start-up it inspects the AUTHORING stream and, if the stream still lists
authoring.image.* (single-token wildcard), replaces those subjects with
authoring.image.> — because authoring.image.* does not match
authoring.image.job.requested. The stream JSON checked into the repo is the narrow
version, so re-applying it as-is silently stops every image request from reaching this worker
(app/worker.py:238-275, and ARCHITECTURE.md §3.3).
| Who uses it | No human calls it. authoring-service-v2 sends every job over NATS on behalf of an admin. |
| Runtime | FastAPI probe shell running the worker as its lifespan task · Deployment image-worker, replicas: 1 (authoring-image-worker/k8s/deployment.yaml:9) |
| Database | Own Postgres database gemini_images_db (alembic; ARCHITECTURE.md §3.4 — sole writer) |
| Redis | users-auth-redis DB index 6 (k8s/configmap.yaml:13) — per-tenant rate limit (25 jobs/min) and 24 h event de-duplication |
| NATS streams | AUTHORING (authoring.image.> after the boot-time widening) for requests; cancellations arrive on core NATS, not JetStream; usage rows go to AUTHORING_USAGE |
| External APIs | Gemini — synchronous generateContent and the Batch API (ARCHITECTURE.md §3.6). In-cluster HTTP: asset-manager for upload URLs and asset lookups (edge H8) |
| Entry points | 4 HTTP routes (health only) · 3 NATS subscriptions (2 durable + 1 core) · 2 scanned published subjects (4 more published via config-held subject names, see §5) · 2 background loops |
2. Feature map
flowchart LR ADMIN["Admin (authoring UI)"] --> AV2[authoring-service-v2] AV2 --> F1["Render now (interactive)"] AV2 --> F2["Render overnight (batch, ~50% cheaper)"] AV2 --> F3["Cancel a run"] F1 --> S1["authoring.image.job.requested"] F2 --> S2["authoring.image.batch.requested"] F3 --> S3["authoring.tool-call.cancelled"] S1 --> GEM[Gemini] S2 --> GEM GEM --> AM["asset-manager (stored image)"] AM --> BACK["job.ready / batch.ready → authoring-v2"]
3. Features
Admin
Cancel a run and stop paying for its images live
An admin who abandons a course generation should not keep being billed for pictures nobody will see. When authoring-v2 cancels a run it broadcasts the run id; this worker remembers it and skips any image for that run which has not started yet, and refuses to add new requests to the next batch. Work already submitted to Gemini still completes and still costs money.
- Entry points
authoring.tool-call.cancelled(core NATS subscription, no durable)- Touches
- In-memory cancelled-run set; the batch accumulator
- Related
- Known limits: the run-id set is never pruned, and a cancel published while the pod reconnects is lost — core NATS has no replay (
bug-hunt-reports/authoring-image-worker.md) - Evidence
- subscription
app/worker.py:409· publisherauthoring-service-v2/app/tools/handlers/orchestrator_handlers.py:550(thecancel-runtool,authoring-service-v2/docs/frontend-integration.md:381)
Employee (mobile)
None. Employees see the stored image through micro-learning and the CDN, never this worker.
Internal (other services)
Render an image while the admin waits live
The interactive path. An admin presses "generate image" on a block, or a generation run finishes writing a lesson and asks for its illustrations; either way the worker renders in seconds, uploads the picture through asset-manager, and reports the asset id back so the block stops showing a placeholder. It rate-limits each tenant to 25 renders a minute so one busy course cannot starve everyone else.
- Entry points
authoring.image.job.requested(durableimage-worker-sync, streamAUTHORING) →authoring.image.job.ready/.failed/.progress- Touches
image_jobs,image_references, Redis (rate limit + dedup), Gemini, asset-manager- Related
- Tools
generate-image/regenerate-image; up to 20 renders run concurrently (IMAGE_SYNC_CONCURRENCY,k8s/configmap.yaml:35) - Evidence
- consumer
app/worker.py:360· publisherslibs/oper-tools/oper_tools/catalog.json:1412,1456viaauthoring-service-v2/app/services/tool_dispatcher.py:287, plus fan-out atauthoring-service-v2/app/consumers/content_consumer.py:100-102· results consumed atauthoring-service-v2/app/consumers/register.py:53-54
Render a pile of images overnight for half the price live
Bulk work — a tenant onboarding, a brand refresh, a whole course nobody is watching — goes on the batch path. Requests are collected for a minute (or until the buffer fills), handed to Gemini's Batch API as one job, and the results are drained back into individual "image ready" events as they land. Same pictures, roughly half the cost, no SLA.
- Entry points
authoring.image.batch.requested(durableimage-worker-batch) →authoring.image.batch.ready/.failed- Touches
image_batch_jobs,image_batch_requests,image_jobs, Gemini Batch API, asset-manager- Related
- Chosen by
input.batch=trueon the image tools, or by authoring-v2'sdoc_to_module_image_batchflag (authoring-service-v2/app/workflows/doc_to_module.py:45-47) - Evidence
- consumer
app/worker.py:392· publishersauthoring-service-v2/app/services/tool_dispatcher.py:296andauthoring-service-v2/app/services/extraction_orchestration.py:67-73· results consumed atauthoring-service-v2/app/consumers/register.py:56-57
Report what each image cost live
Each rendered image is reported as a usage row with its model and price class, which is how authoring-v2 charges the tenant's credit balance and how the platform knows whether images or text dominate a course's cost.
- Entry points
authoring.usage.event(publish only)- Touches
AUTHORING_USAGEstream- Related
- Five of the six authoring pipeline workers publish this subject; authoring-v2's usage consumer is its only reader.
- Evidence
app/worker.py:1333· consumed atauthoring-service-v2/app/consumers/register.py:68
Background
Batch collector and batch poller live
Two loops keep the cheap path moving without anyone asking: one flushes the accumulated requests every 60 seconds so a small batch is not stuck waiting for a full buffer, and one walks the unfinished Gemini batch jobs, notices when they finish, and turns each result line into a stored image plus a "ready" event. Both only start when a Gemini key is configured.
- Entry points
- loops
BatchAccumulator._timer_loopandImageWorker._batch_poll_loop - Touches
image_batch_jobs, Gemini Batch API, asset-manager- Related
- Started from the worker's own start-up, not from a scheduler (
app/worker.py:176-187); with noGEMINI_API_KEYthe batch path is disabled and batch requests are acked and dropped - Evidence
app/batch_pipeline.py:89,app/worker.py:182, loop bodyapp/worker.py:829-891
Stale-job sweep at boot live
A pod that dies mid-render leaves jobs marked "generating" forever, which would show an admin a spinner that never resolves. On every start-up the worker marks anything stuck for more than ten minutes as failed, so the UI shows an honest error and the block can be retried.
- Entry points
sweep_stale_generating_jobs(older_than_minutes=10)during start-up- Touches
image_jobs- Related
- Runs once per pod start, not on a timer
- Evidence
app/worker.py:160
4. API reference
No business API. Four health routes exist because the worker contract asks for both the legacy
pair and the /health/live + /health/ready pair.
| Method | Path | Auth | Feature | Callers | Verdict |
|---|---|---|---|---|---|
| GET | /health | none | Liveness | kubelet livenessProbe (k8s/deployment.yaml:63) | live |
| GET | /ready | none | Readiness — worker loop running | kubelet readinessProbe (k8s/deployment.yaml:76) | live |
| GET | /health/live | none | Spec-mandated liveness mirror | No probe and no in-repo client; kept to satisfy worker-integration.md §10 | suspect |
| GET | /health/ready | none | Spec-mandated readiness mirror (NATS + DB + running) | No probe and no in-repo client; strictly better than /ready — the probe should point here | suspect |
5. Async contracts
Consumes
| Subject | Stream | Durable | Published by | Feature | Verdict |
|---|---|---|---|---|---|
authoring.image.job.requested | AUTHORING | image-worker-sync (queue image-worker-sync) | authoring-service-v2 — tool_dispatcher.py:287 (tools generate-image/regenerate-image), consumers/content_consumer.py:100-102, workflows/doc_to_module.py:47 | Render now | live |
authoring.image.batch.requested | AUTHORING | image-worker-batch (queue image-worker-batch) | authoring-service-v2 — services/tool_dispatcher.py:296, services/extraction_orchestration.py:67, workflows/doc_to_module.py:46 | Render overnight | live |
authoring.tool-call.cancelled | core NATS (no stream, no replay) | none — every pod receives every message | authoring-service-v2 — app/tools/handlers/orchestrator_handlers.py:550 | Cancel a run | live |
Publishes
The scanner sees the two subjects published with a literal string. The four result subjects are published through settings-held names, so they are listed here with their config line and their authoring-v2 consumer.
| Subject | Consumed by | Feature | Verdict |
|---|---|---|---|
authoring.image.job.progress app/worker.py:1220 | authoring-service-v2 image_consumer.on_image_job_progress (register.py:59) — progress bar only | Render now | live |
authoring.usage.event app/worker.py:1333 | authoring-service-v2 usage_consumer (register.py:68) | Credit metering | live |
authoring.image.job.ready app/config.py:119-121 | authoring-service-v2 (register.py:53) | Render now | live |
authoring.image.job.failed app/config.py:122-124 | authoring-service-v2 (register.py:54) | Render now | live |
authoring.image.batch.ready app/config.py:133-135 | authoring-service-v2 (register.py:56) | Render overnight | live |
authoring.image.batch.failed app/config.py:136-138 | authoring-service-v2 (register.py:57) | Render overnight | live |
Background jobs
| Job | Schedule | What it does | Verdict |
|---|---|---|---|
_timer_loop (app/batch_pipeline.py:89) | Every BATCH_FLUSH_INTERVAL_SECONDS=60 (k8s/configmap.yaml:40) | Flushes the accumulated batch requests so a partial batch still gets submitted | live |
_batch_poll_loop (app/worker.py:182) | Every BATCH_POLL_INTERVAL_SECONDS, backing off to 300 s after errors | Polls unfinished Gemini batch jobs and processes terminal states | live |
_run_sync_message (app/worker.py:486) | Not scheduled — one task per sync message | Bounded-concurrent dispatch; previously serial, which made a 42-image module take 40+ minutes | live |
_consume_batch_result (app/worker.py:932) | Not scheduled — one task per batch result line | Uploads and announces one finished image from a batch, windowed so a 100-image batch cannot block the poll loop | live |
6. Data it owns
Sole writer of gemini_images_db (ARCHITECTURE.md §3.4). Nothing else
reads or writes it, and this worker writes no other service's tables.
| Table | What it holds | Written by |
|---|---|---|
image_jobs | One row per requested image: tenant, prompt, status, resulting asset, parent job for regenerations. The audit trail behind "where did this picture come from". | This worker only (alembic/versions/001_initial_schema.py:22) |
image_references | The reference images (up to five, weighted) a job was conditioned on. | This worker only (alembic/versions/001_initial_schema.py:46) |
image_batch_jobs | One row per Gemini Batch API submission, keyed by the provider's batch id, with its lifecycle state — this is what makes an interrupted batch resumable. | This worker only (alembic/versions/003_image_batch_jobs.py:26) |
image_batch_requests | The individual requests inside a batch, keyed by event id, so each result line can be matched back to the block that asked for it. | This worker only (alembic/versions/003_image_batch_jobs.py:54) |
Redis DB 6 holds two short-lived things, not durable data: the per-tenant rate-limit window and a
24-hour event_id de-duplication key that stops a JetStream redelivery from re-rendering
(and re-billing) an image.
7. Dependencies
flowchart LR
AV2[authoring-service-v2] -. "image.job / image.batch requested" .-> IW[image-worker]
AV2 -. "tool-call.cancelled (core NATS)" .-> IW
IW -. "job/batch ready · failed · progress · usage" .-> AV2
IW -- "HTTP H8: upload + asset URL" --> AM[asset-manager-service]
IW -- render --> GEM["Gemini (sync + Batch API)"]
IW -- "jobs · batches" --> DB[("gemini_images_db")]
IW -- "rate limit · dedup" --> REDIS[("users-auth-redis db 6")]
8. Dead-code verdicts
Every entry point with no in-repo caller. Deleting is a separate decision — see the hub roll-up.
| Entry point | Kind | Verdict | Evidence |
|---|---|---|---|
GET /health/live | HTTP route | suspect | Searched every k8s manifest in this service for the path and every service's client code: no probe and no caller. It exists only to satisfy authoring-service-v2/docs/worker-integration.md §10. Not dead: the kubelet reaches it, and the spec names it as the contract path. |
GET /health/ready | HTTP route | suspect | Same search result (k8s/deployment.yaml:74-79 points the readiness probe at /ready). This handler is the more accurate one — it checks NATS, the DB pool and the worker loop (app/main.py:64-75) where /ready checks only worker.running. Resolving the duplication is a manifest change, not a code deletion. |
All three subscriptions and all six published subjects have named in-repo counterparties, so no NATS entry point is dead.
9. Sources
- authoring-image-worker/README.md and USAGE_GUIDE.md — both stale: they document a single subject trio
authoring.image.generate/.generated/.failedthat no longer exists in the code. The live contract is the sync/batch pair in §5, taken fromapp/config.py:111-145andapp/worker.py:1-14. - bug-hunt-reports/authoring-image-worker.md — committed keys (#1), batch durability and orphaned-result findings (#2, #3, #9, #10, #12), the unpruned cancellation set
- ARCHITECTURE.md §3.1 (spoke topology), §3.2 (edge H8), §3.3 (the
AUTHORINGstream-widening trap), §3.4 (gemini_images_db), §3.5 (Redis index 6), §3.6 (Gemini egress) authoring-service-v2/docs/worker-integration.md§4.4 and §10 — sync-vs-batch routing, cost/SLA table, health-route contractapp/worker.py,app/batch_pipeline.py,app/config.py,alembic/versions/- Scan output
tools/feature-docs/out/authoring-image-worker.json