Engine CSV Ingestion Jobs: In-Memory, YAML-Out, No Webhook
traceo intermediate 6 min read
ELI5
You drop a stack of forms at the engine’s desk and get a ticket. A clerk processes them in the back room while you keep asking about your ticket. The surprise is where the forms go. The clerk files them as YAML files in a temp folder on the engine’s own disk, not in the database the web app reads, and nobody phones the MCP server to say they are done.
Technical Deep Dive
Source: traceo-cat/engine/src/engine/server.py:229-358, engine/src/engine/ingestion/jobs.py:30-37, :97-102, :143-215, engine/src/engine/ingestion/yaml_writer.py:12-24, migrations/008_reconcile_schema.sql:121-124 (job_status enum); web/apps/client/src/services/engineClient/client.ts; live engine /health; traceo-cat origin/main 7d647ab, read 2026-10-25
Endpoints (engine :8001)
| Endpoint | Behaviour |
|---|---|
POST /jobs/ingest | Requires a filename with extension .csv, .xlsx or .xls (else 400). Reads the whole body into memory, creates a Job, schedules process_job on FastAPI BackgroundTasks, returns 202 {job_id, status: "pending"} |
GET /jobs/{job_id} | Status, progress and issues for one job |
GET /jobs | Lists jobs from the in-memory manager, filterable by status. It does not filter by user, workspace or tenant: any authenticated caller sees every job’s id, filename and progress |
What process_job does
- Writes
job.contentto a temp file with a.csvsuffix. load_requirements_from_csvsetstotal_rows.run_csv_ingestion_job_with_report(..., requirements_root, dry_run=False)validates each row and writesrequirements/<ID>/definition.ymlviawrite_requirement_definition.- Copies per-row issues into
JobIssueRecords, setsprocessed_rows = total_rows, thencompleted(orfailedwith aninternal_errorissue).
AppState builds JobManager() with no root, so the root is tempfile.gettempdir()/traceo-ingestion inside the engine container. Nothing in this path opens a Postgres connection. The engine has a repositories/postgres.py with scoped_connection(workspace_id), but a search on 2026-10-25 found no caller of it from server.py or ingestion/. So a completed job leaves YAML on an ephemeral container disk and no rows in requirements.
.xlsx and .xls pass the extension check, but step 1 always parses the bytes as CSV. An Excel upload most likely fails during parsing. That is inferred from the code and was not executed (UNVERIFIED).
Job state machine
stateDiagram-v2 [*] --> pending : POST /jobs/ingest (202) pending --> running : background task starts running --> completed : report collected running --> failed : any exception completed --> [*] failed --> [*]JobStatus in jobs.py has pending, running, completed and failed. The database enum job_status in 008 is pending, processing, completed, failed, cancelled, a different vocabulary, used by the ingestion_jobs table. Only the uncalled engine/repositories/postgres.py references that table. There is no cancel. A restart drops every job, because JobManager is a dict behind a lock.
No webhook, and no live web caller
The v1.0 card said the engine posts a completion webhook to the MCP server. It does not. A search of engine/src/engine/ for webhook, httpx.post and requests.post returned nothing. The client learns the outcome only by polling GET /jobs/{id}.
The web client’s engineClient calls GET /runs, a route the engine does not serve. A search of web/ for jobs/ingest found no caller. The requirement import that users actually reach is the MCP tool import_requirements (traceo-008), which writes through the RLS-scoped repository.
sequenceDiagram participant C as Caller with JWT participant RL as RateLimit + Auth participant E as /jobs/ingest participant JM as JobManager (memory) participant D as Engine temp disk C->>RL: POST /jobs/ingest (file) RL->>E: authenticated E->>JM: create_job(job_id, content) E-->>C: 202 job_id, pending JM->>JM: running JM->>D: write requirements/ID/definition.yml per row JM->>JM: completed with issues loop poll C->>E: GET /jobs/job_id E-->>C: status, progress, issues endRust backend
engine/railway.toml builds with DISABLE_RUST=1, and the live /health on 2026-10-25 reported rust_backend: fallback. Production runs the pure-Python validators.
Key Terms
- JobManager → an in-memory, lock-guarded registry of jobs. It does not persist across restarts.
definition.yml→ the per-requirement YAML file the ingestion writes underrequirements_root.requirements_root→tempfile.gettempdir()/traceo-ingestionby default, inside the container.- JobIssueRecord → a validation finding with a severity and an optional
row_number.
Q&A
Q: What HTTP status does /jobs/ingest return on accept?
A: 202 Accepted, with {job_id, status: "pending"}. A missing filename or a disallowed extension gets 400.
Q: What are the four job states?
A: pending, running, completed, failed (JobStatus in ingestion/jobs.py). The database enum’s processing/cancelled values belong to the ingestion_jobs table, which the live path never writes.
Q: Where does a processed row end up?
A: In a YAML file, requirements/<ID>/definition.yml, under the engine container’s temp directory. Not in Postgres.
Q: How does the client know the job has finished?
A: By polling GET /jobs/{job_id}. There is no webhook and no push.
Examples
You upload reqs.csv and the job reaches completed with zero issues, but the web app shows nothing new. That is expected on this path: the rows became YAML files on the engine’s disk, and the next redeploy erases them. To get requirements into a workspace, use the MCP import_requirements tool.
neighbors on the map
- Producer / Reader Split onboarding a new producer repo
- Site Provisioning Saga State Machine debugging a site stuck mid-provision
- Observability State & Gaps (as served, 2026-10) writing an alert and checking the metric is actually emitted