authoring-image-worker — business features

Renders the pictures that go inside a lesson, either right now or overnight at half price · ← platform hub · entry points verified against tools/feature-docs/out/authoring-image-worker.json

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 itNo human calls it. authoring-service-v2 sends every job over NATS on behalf of an admin.
RuntimeFastAPI probe shell running the worker as its lifespan task · Deployment image-worker, replicas: 1 (authoring-image-worker/k8s/deployment.yaml:9)
DatabaseOwn Postgres database gemini_images_db (alembic; ARCHITECTURE.md §3.4 — sole writer)
Redisusers-auth-redis DB index 6 (k8s/configmap.yaml:13) — per-tenant rate limit (25 jobs/min) and 24 h event de-duplication
NATS streamsAUTHORING (authoring.image.> after the boot-time widening) for requests; cancellations arrive on core NATS, not JetStream; usage rows go to AUTHORING_USAGE
External APIsGemini — synchronous generateContent and the Batch API (ARCHITECTURE.md §3.6). In-cluster HTTP: asset-manager for upload URLs and asset lookups (edge H8)
Entry points4 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

admin

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 · publisher authoring-service-v2/app/tools/handlers/orchestrator_handlers.py:550 (the cancel-run tool, 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

authoring-service-v2

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 (durable image-worker-sync, stream AUTHORING) → 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 · publishers libs/oper-tools/oper_tools/catalog.json:1412,1456 via authoring-service-v2/app/services/tool_dispatcher.py:287, plus fan-out at authoring-service-v2/app/consumers/content_consumer.py:100-102 · results consumed at authoring-service-v2/app/consumers/register.py:53-54

Render a pile of images overnight for half the price live

authoring-service-v2

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 (durable image-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=true on the image tools, or by authoring-v2's doc_to_module_image_batch flag (authoring-service-v2/app/workflows/doc_to_module.py:45-47)
Evidence
consumer app/worker.py:392 · publishers authoring-service-v2/app/services/tool_dispatcher.py:296 and authoring-service-v2/app/services/extraction_orchestration.py:67-73 · results consumed at authoring-service-v2/app/consumers/register.py:56-57

Report what each image cost live

authoring-service-v2

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_USAGE stream
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 at authoring-service-v2/app/consumers/register.py:68

Background

Batch collector and batch poller live

background

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_loop and ImageWorker._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 no GEMINI_API_KEY the batch path is disabled and batch requests are acked and dropped
Evidence
app/batch_pipeline.py:89, app/worker.py:182, loop body app/worker.py:829-891

Stale-job sweep at boot live

background

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.

MethodPathAuthFeatureCallersVerdict
GET/healthnoneLivenesskubelet livenessProbe (k8s/deployment.yaml:63)live
GET/readynoneReadiness — worker loop runningkubelet readinessProbe (k8s/deployment.yaml:76)live
GET/health/livenoneSpec-mandated liveness mirrorNo probe and no in-repo client; kept to satisfy worker-integration.md §10suspect
GET/health/readynoneSpec-mandated readiness mirror (NATS + DB + running)No probe and no in-repo client; strictly better than /ready — the probe should point heresuspect

5. Async contracts

Consumes

SubjectStreamDurablePublished byFeatureVerdict
authoring.image.job.requestedAUTHORINGimage-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:47Render nowlive
authoring.image.batch.requestedAUTHORINGimage-worker-batch (queue image-worker-batch)authoring-service-v2 — services/tool_dispatcher.py:296, services/extraction_orchestration.py:67, workflows/doc_to_module.py:46Render overnightlive
authoring.tool-call.cancelledcore NATS (no stream, no replay)none — every pod receives every messageauthoring-service-v2 — app/tools/handlers/orchestrator_handlers.py:550Cancel a runlive

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.

SubjectConsumed byFeatureVerdict
authoring.image.job.progress app/worker.py:1220authoring-service-v2 image_consumer.on_image_job_progress (register.py:59) — progress bar onlyRender nowlive
authoring.usage.event app/worker.py:1333authoring-service-v2 usage_consumer (register.py:68)Credit meteringlive
authoring.image.job.ready app/config.py:119-121authoring-service-v2 (register.py:53)Render nowlive
authoring.image.job.failed app/config.py:122-124authoring-service-v2 (register.py:54)Render nowlive
authoring.image.batch.ready app/config.py:133-135authoring-service-v2 (register.py:56)Render overnightlive
authoring.image.batch.failed app/config.py:136-138authoring-service-v2 (register.py:57)Render overnightlive

Background jobs

JobScheduleWhat it doesVerdict
_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 submittedlive
_batch_poll_loop (app/worker.py:182)Every BATCH_POLL_INTERVAL_SECONDS, backing off to 300 s after errorsPolls unfinished Gemini batch jobs and processes terminal stateslive
_run_sync_message (app/worker.py:486)Not scheduled — one task per sync messageBounded-concurrent dispatch; previously serial, which made a 42-image module take 40+ minuteslive
_consume_batch_result (app/worker.py:932)Not scheduled — one task per batch result lineUploads and announces one finished image from a batch, windowed so a 100-image batch cannot block the poll looplive

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.

TableWhat it holdsWritten by
image_jobsOne 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_referencesThe reference images (up to five, weighted) a job was conditioned on.This worker only (alembic/versions/001_initial_schema.py:46)
image_batch_jobsOne 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_requestsThe 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 pointKindVerdictEvidence
GET /health/liveHTTP routesuspectSearched 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/readyHTTP routesuspectSame 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