Re-cut of ../evidence/20260817-walmart-recon.md against the canon matrix, plus
a Lapses section. Citations are that recon's, read against the working tree at
/Users/alcatraz627/Code/Versable/walmart-mvp on 2026-08-17. Live Cloud Run
state read the same day.
Two pipeline generations coexist here. Everything below describes the
current one (Job to Part to PartError, "pipeline v2"). The legacy
Catalog to Upload to UploadItem pipeline appears only where the two
diverge, which is exactly where the interesting lapses are: the legacy pipeline
has crash recovery and cancel, and the current one does not.
1. What it is#
An app with its runner inside. Python FastAPI serving a SPA, Postgres on Cloud
SQL via SQLAlchemy 2 async, Redis with arq for the queue, GCS for bytes,
deployed as four Cloud Run services in project walmart-496912:
walmart-api, walmart-api-prod, walmart-worker, walmart-worker-prod. One
image, two roles, two environments.
Callers are humans in a browser holding a bearer JWT. There is no machine-to-machine inbound path.
The domain is publishing a supplier catalog to Walmart: eight ordered stages from ingest through taxonomy, spec mapping, scrape, images, content, and finally a feed submission to Walmart's API.
2. Shape of the runner#
A single-stage-at-a-time state machine, not a DAG and not a queue of independent
tasks. Job.stage (backend/app/models.py:607) names which of eight ordered
stages the job is on; Job.stage_state (:608) tracks that stage's own
progress.
trigger (stage task finishes · review row resolved · skip) │ ▼ evaluate_job() orchestrator.py:507-556 the single entry point every trigger calls │ │ gates generically on two per-part structures: │ Part.stage_coverage[stage] did something touch this part │ open PartError rows did it leave a problem │ │ enqueues at most one stage's work, then recurses │ synchronously through stages with nothing to do ▼ arq over Redis deterministic id {task_name}-{job_id} │ max_jobs=2 · job_timeout=3600 ▼ thin stage wrapper jobs.py:928-1122 load job → call one domain module's *_service_v2.py → record summary/log → flip stage_state → evaluate_job againThe stage tasks are near-identical wrappers. The seam between "what advances a job" and "what a stage does" is drawn at the module boundary rather than inside a shared function.
3. Concern by concern#
| # | Concern | Mechanism here | Cite | Vendor |
|---|---|---|---|---|
| 1 | Runner/payload seam | orchestrator.py, jobs.py, worker.py vs ingest/, taxonomy/, spec/, scraping/, images/, content/, walmart/ | orchestrator.py, jobs.py:928-1122 | no |
| 2 | Caller identity | bearer JWT HS256, sub = user id, 2-week expiry; user row loaded every request | security.py:40-58, routes/_common.py:44-55, config.py:129 | no |
| 3 | Tenancy | X-Org-Id header validated against Membership; no tenant id trusted from the JWT | routes/_common.py:94-106 | no |
| 4 | Roles / RBAC | require_membership(..., manage=True) against MANAGER_ROLES (owner/admin) | routes/_common.py:67-80, routes/jobs.py:185 | no |
| 5 | Submit surface | multipart upload; files to storage, rows created, duplicate part-number pre-check synchronous in-request | routes/jobs.py:48-154 | no |
| 6 | Job state ownership | app owns; Job row | models.py:592-637 | no |
| 7 | Derived vs stored status | stored at every level; Job.* mutated only in evaluate_job, Part.status from six call sites | models.py:530-538, orchestrator.py:507-556 | no |
| 8 | Dispatch / queue | arq over Redis, deterministic job ids | jobs.py:907-922, orchestrator.py:495-504 | Redis |
| 9 | Concurrency | arq max_jobs=2, dropped from 5 after an OOM kill | worker.py:79-122,116-121 | no |
| 10 | Retry / backoff | none for the current pipeline; job_timeout=3600 is the only ceiling | worker.py:79-122 | no |
| 11 | Heartbeat / crash recovery | absent for Job; present for legacy Catalog (auto_recover_stage, MAX_AUTO_RETRIES=3, 2-min cron) | dispatch.py, jobs.py:91-119 | no |
| 12 | Cancel | absent for Job; legacy Catalog has request_stage_cancel with live/dead worker branches | routes/_common.py:146-171 | no |
| 13 | Resume / checkpoint | none; ingestion merges by part_number so a re-run is upsert-idempotent | ingest/job_ingest.py | no |
| 14 | Idempotency | deterministic arq ids; part_error_id deterministic; publish dedup window | models.py:586-589, walmart/service_v2.py:631-635, config.py:171-173 | no |
| 15 | Storage of inputs | rows in Postgres (JobFile); bytes in GCS or local, keyed {org_id}/jobs/{uuid}_{filename} | models.py:592-637, storage.py:1-40 | GCS, Cloud SQL |
| 16 | Storage of outcomes | Part row per item, overwritten per stage, not append-only; PartError per (part, field) | models.py:639-725,727- | no |
| 17 | Results reporting | routes per resource | routes/jobs.py | no |
| 18 | Logs per job and item | stage log capped at 20 lines, prior rows deleted on write, so latest-run-only; summaries as JSON on the job | orchestrator.py:255-264,267-272 | no |
| 19 | Tracing | Langfuse, optional, no-op when the key is unset, initialized once per process at worker boot | observability.py, worker.py:28 | Langfuse |
| 20 | Usage metering | in-process ContextVar buffer flushed every 50 events or at job end; best-effort by design | usage.py | no |
| 21 | Limits / quotas | none | ||
| 22 | Caching | Redis is queue and rate slots only, not results | worker.py:64-76 | Redis |
| 23 | Rate limiting outbound | Redis-backed fleet-wide slot limiter, per vendor | ratelimit.py, config.py:64-67 | Redis |
| 24 | Config | pydantic_settings.BaseSettings, one lru_cached Settings singleton | config.py | no |
| 25 | Secrets | env | config.py | no |
| 26 | Human in the loop | PartError rows are the queue; resolve or skip re-evaluates the job | orchestrator.py | no |
| 27 | Completion signalling | poll; Walmart feed status by self-re-enqueue with backoff | jobs.py:636-771 | no |
| 28 | Capability discovery | none | ||
| 29 | Versioning | GET /api/build-info reports the commit SHA of the running image | ARCHITECTURE.md:126-128 | no |
| 30 | Health / readiness | /api/build-info | ARCHITECTURE.md:126-128 | no |
| 31 | Provisioning | branch to env mapping, one image two services, Cloud Build | ARCHITECTURE.md | Cloud Build |
| 32 | Local dev | storage_backend=local, stub_feed_submit defaults true and fakes the Walmart round trip | config.py, jobs.py:674-694 | no |
| 33 | Output delivery | GCS public bucket for rehosted images, kept separate from the private source bucket | config.py:138-144 | GCS |
| 34 | Data retention | none | ||
| 35 | Multiple versions | two pipeline generations coexist in one codebase, by accretion | dispatch.py vs orchestrator.py | no |
| 36 | Conformance | none | ||
| 37 | Outputs and exports | split by generation. The legacy one has a real export layer separate from the row data: one xlsx per Walmart data model plus a misc fallback and a manifest, zipped to GCS, tracked by its own export_status independent of enrichment and mapping. The current one has no export layer at all, because publish is the output: the payload is built fresh from Part at submit time and persists only as WalmartFeedSubmission.payload_json, the locked record of what was sent. The seller never downloads a file for v2 | backend/app/export/loadsheet.py:1-15, models.py:109-136,388-391, walmart/service_v2.py:442-469 | GCS + Walmart Marketplace |
| 38 | Data ownership split | two pipeline generations share one Postgres and one codebase: the legacy one file-centric (Catalog, Upload, UploadItem), the current one part-centric where a Part is a durable per-(org, part_number) ledger row that accretes data across eight stages, with PartError as the review queue. GCS holds uploads, rehosted images and a snapshot of Walmart's own schema; Redis is the arq queue plus small caches. Walmart Marketplace is the external system of record for anything submitted: the app mirrors it and never owns that state once live | backend/app/models.py:519-528, ../evidence/20260818-data-model-split/walmart-mvp.md | Postgres + GCS + Redis + Walmart |
This is the most vendor-entangled instance: Cloud SQL, Redis over a VPC connector, GCS, Walmart's OAuth and feed API, Gemini, Oxylabs, ScraperAPI, and the shared Extractor Service. The runner itself (arq, SQLAlchemy, pydantic) is vendor-neutral; the coupling is concentrated in the payload modules.
4. Runner vs payload#
The seam is clean and drawn at the directory level.
Runner: orchestrator.py, jobs.py, worker.py, dispatch.py, models.py,
routes/_common.py, routes/jobs.py, usage.py, security.py, config.py,
db.py, storage.py.
Payload: ingest/, taxonomy/, spec/, scraping/, images/, content/,
walmart/, plus gemini.py and pcdb/ as shared vendor clients. Each exposes
a *_service_v2.py entry point the runner calls by name.
Two named leaks, both from the recon:
-
orchestrator.pycarries two lines of Walmart vocabulary:CONTENT_SCHEMA_KEYS(:78) andIMAGE_FIELD_KEYS(:90). The module's own comment (:69-73) says this was deliberate, so neitherspec/norcontent/has to import the other. A documented tradeoff, not an oversight, but the generic orchestrator does know two Walmart facts. -
poll_feed_status(backend/app/jobs.py:636) builds a second job-lifecycle machine inside the runner: self-re-enqueue with exponential backoff, a time ceiling, an abort flag, terminal-state transitions. The consequence: "how does a long-running background operation get retried or timed out here" has two different answers depending which file you are in.An earlier version of this doc attributed that function to
walmart/service_v2.pyand called it a payload leak. Both halves were wrong.rg -n "def poll_feed_status"over the whole tree returns exactly one hit, injobs.py, which this doc's own runner list includes;walmart/service_v2.pyonly calls it. The finding survives the correction but changes shape: this is duplication within the runner, not a payload reaching for something the runner withheld.
Leak 2 still argues for a runner-provided wait primitive, but it is no longer the same failure as speedway's copy-pasted checkpointing (row 13). Speedway's three copies are genuinely in payload files. Speedway remains the only witness in the estate for the payload-leak version of that rule.
5. Deliberate decisions#
Status is stored, not derived, with a single mutation point at the job level.
evaluate_job is the only writer of Job.status/stage/stage_state, and
orchestrator.py:1-9 names it as such specifically to hold that line. This is
the deliberate opposite of speedway and versable-runner, and the tradeoff is
stated: always-consistent-by-construction is exchanged for must-be-right-at-
every-call-site.
Concurrency is capped low and the reason is on record. arq max_jobs went
from 5 to 2 after image_normalization OOM-killed a 512Mi prod worker on
2026-08-11 (worker.py:116-121). A memory-pressure decision, not a throughput
default. This is the only instance whose concurrency number has a documented
incident behind it.
Submit cost is synchronous and non-trivial. create_job uploads every file
and runs the duplicate part-number pre-check inside the request cycle
(routes/jobs.py:48-154). Only ingestion is deferred. The opposite of
versable-runner's O(1) submit, and a deliberate choice to fail fast on bad
input.
A stage running cleanly is not the same as a stage doing something
(jobs.py:31-43). matched = processed - flagged silently reads as success for
a stage like scrape that never files PartError rows, even when it fetched
nothing. matched_override exists as the escape hatch. This is a real bug class
someone hit and fixed, and it is the best articulation in the estate of why
"no errors" is not a success signal.
Publish is a button, not a stage (routes/jobs.py:176-189), and uniquely
requires owner/admin.
Live submission is double-gated: a global settings flag and a per-org DB flag
must both be true (config.py:155-159, models.py:46-51), separate again from
stub_feed_submit which fakes the whole Walmart round trip for local testing.
Metering is deliberately lossy. usage.py's docstring states a crash can
lose one autoflush buffer, "which is fine for dashboard stats". An explicit
accuracy-for-simplicity tradeoff.
6. Lapses#
No crash recovery on the current pipeline (11). Canon settles that any run
longer than one dispatch needs a heartbeat. Job has no heartbeat column and no
retry counter; recover_stale_jobs queries only the legacy Catalog table. The
only backstop for a job stuck at stage_state="running" because its worker died
is arq's job_timeout=3600. The legacy pipeline this replaced had recovery,
so this is a regression, not an unbuilt feature.
No cancel on the current pipeline (12). Canon settles cooperative cancel as
mandatory. There is no cancel route and no column. Worse, routes/jobs.py:215-216
carries a commented-out guard that would have blocked deleting a running job,
so today a job can be deleted mid-stage while its arq task is still in flight.
Again, the legacy pipeline had this.
Those two together are the sharpest finding in the estate: a rewrite dropped two capabilities the thing it replaced already had, and nothing failed as a result, because no artifact represented the old behaviour.
Part.status has six mutation points (upsert_part_error,
clear_part_error, finalize_resolve, submit_job_parts,
reconcile_submission, mark_submission_stalled). Canon 7's recommendation is
derive-where-cheap, else one mutation point. The job level honours that; the
part level does not.
Stage logs are latest-run-only by construction (18). A write deletes the stage's prior rows, and the cap is 20 lines truncated to 500 chars. Debugging a job that failed two runs ago is impossible. Canon 18 is open; this is the weakest of the three answers.
No limits or quotas (21). Speedway has checkLimits; this instance has
nothing between a caller and the vendor spend.
No machine-to-machine auth (2). Bearer JWT for the SPA only. Confirmed
absent by grep across routes/, security.py and config.py; every
*_api_key setting is an outbound vendor credential.
Two pipeline generations coexist (35). By accretion rather than design, the same shape as versable-runner's two forks. Canon 35 calls running several versions at once the stated design goal; both instances that do it arrived there by accident and neither can route between them.
No capability discovery (28), no conformance (36), no retention (34).
Worth recording as the opposite of a lapse: this instance has the best outbound rate limiting in the estate (23, a Redis-backed fleet-wide slot limiter per vendor, which canon already names as the shape to copy), the tracing arrangement canon recommends (19, Langfuse behind a no-op default), and the provisioning canon recommends (31, branch to env mapping, one image two services).
7. Unproven#
Inherited from the recon, which flagged them carefully:
- The no-crash-recovery finding rests on absence evidence.
Jobhas no heartbeat or retry column, the only registered stale sweep queriesCatalog, and a full-tree grep forheartbeatandstalehits only legacy-pipeline files. What was not verified: whether a worker process death (rather than an in-task exception) actually triggers arq's own timeout-and-retry, or just leaves the in-progress key to expire unwatched. arq's source was not read. That is the next thing to read if this gap matters. - The no-cancel finding is grep-confirmed, not merely unfound. Cancel
machinery exists only against
Catalog's status columns. - API-key auth confirmed absent by grep over the two files that define every auth dependency, though not every router file was opened, so a machine-to-machine path could in principle exist somewhere unread.
- The payload modules' internals (
ingest/job_ingest.py, and each*_service_v2.py) were not read in full. Claims like "part-type matching" or "content generation" come from naming and one-line docstrings.
Added this pass:
- Rows 17, 30 and 31 are transcribed from
ARCHITECTURE.mdand the canon matrix, not from route files I opened. Row 30 in particular lists only/api/build-info; whether a dedicated liveness endpoint exists was not checked, so "health" here may be understated. - The four Cloud Run services in
walmart-496912were read from live state. The api/worker and prod/non-prod split is inferred from their names and the recon's "one image two services" note, not from deploy config I read. - Nothing was executed. No job was submitted, no worker was run.