feat(server): ingest endpoints — upload, launch, poll, and the pattern every long operation reuses (#28) - #100
Merged
Conversation
…n every long operation reuses (#28) Ingest is the first API operation that outlives its request. The shape it sets — launch returns an id, the client polls that id — is what #29, #33 and #35 reuse, so it is worth getting right once. The kernel already anticipated this and was one method short. `IngestJob` is created `pending` and `INGEST_TRANSITIONS` allows `pending -> running`, but the only way to reach a row was `ingest()`, which creates it *and* runs it. So a 202 had nothing to hand back, and a fast refusal left no row at all. `enqueue()` is that transaction extracted; `ingest()` is now `enqueue()` + `resume()`, and `resumable()` is `resume`'s own pre-check without the work, so a caller running the second half elsewhere gets the refusal on its own thread. Registration is upload-only. The kernel registers a source by path, so uploads are staged content-addressed under `<workspace>/uploads/<digest>/` — the same files under the same names land on the same path, which is what makes `SourceService`'s idempotency survive the trip to HTTP. A route taking a server-side path would hand every token holder an arbitrary-directory read, and the surfaces that legitimately hold real paths call the SDK in-process. The run executes on a single-worker `ThreadPoolExecutor` owned by the app. One worker serializes writers against a single-writer store; what that buys the reader is #80's WAL, and there is a test that reads through a held write lock. No migration: `FORMAT_VERSION` stays 11, `VERSION` stays 0.0.1.dev0.
This was referenced Jul 28, 2026
Closed
JArmandoAnaya
added a commit
that referenced
this pull request
Aug 21, 2026
…n every long operation reuses (#28) (#100) Ingest is the first API operation that outlives its request. The shape it sets — launch returns an id, the client polls that id — is what #29, #33 and #35 reuse, so it is worth getting right once. The kernel already anticipated this and was one method short. `IngestJob` is created `pending` and `INGEST_TRANSITIONS` allows `pending -> running`, but the only way to reach a row was `ingest()`, which creates it *and* runs it. So a 202 had nothing to hand back, and a fast refusal left no row at all. `enqueue()` is that transaction extracted; `ingest()` is now `enqueue()` + `resume()`, and `resumable()` is `resume`'s own pre-check without the work, so a caller running the second half elsewhere gets the refusal on its own thread. Registration is upload-only. The kernel registers a source by path, so uploads are staged content-addressed under `<workspace>/uploads/<digest>/` — the same files under the same names land on the same path, which is what makes `SourceService`'s idempotency survive the trip to HTTP. A route taking a server-side path would hand every token holder an arbitrary-directory read, and the surfaces that legitimately hold real paths call the SDK in-process. The run executes on a single-worker `ThreadPoolExecutor` owned by the app. One worker serializes writers against a single-writer store; what that buys the reader is #80's WAL, and there is a test that reads through a held write lock. No migration: `FORMAT_VERSION` stays 11, `VERSION` stays 0.0.1.dev0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #28. Sixth task of M3, branched from
6a1e750(#27).Ingest is the first API operation that outlives its request, so the shape it sets — launch returns an id, the client polls that id — is the one #29, #33 and #35 reuse. Getting it right once is the point of the task.
The seam the kernel was missing
IngestService.ingest()creates the job row and runs it, in one call. A 202 must carry a job id, so the API could not use it: submittingingest()to a worker means the client gets a 202 with nothing to poll, and a fast refusal in transaction 1 leaves no job row at all.The kernel had already reserved the vocabulary —
IngestJobis createdpending,INGEST_TRANSITIONSallowspending → running, andresume()'s docstring calledpending"what a synchronous run never leaves behind but a queued one would". Only the enqueue half was never exposed.IngestService.enqueue(source_id, *, batch_id=None, batch_name=None) -> IngestJob— today's transaction 1, extracted.ingest()is now literallyenqueue()+resume(), and all 95 existing ingest tests passed unchanged.IngestService.resumable(job_id) -> IngestJob—resume's own pre-check without the work, sharing one_resolve_for_runso there is a single spelling. Without itPOST /ingest-jobs/{id}/resumewould 202 a completed job and refuse only in the worker, leaving a client unable to tell a redo from a no-op.BatchService.assets(batch_id) -> list[Asset]— mirrorsDatasetService.assets, withassets_ofbesidejobs_offor the same reason. This is what "→ assets listed" needed; no service read assets by batch.No migration.
FORMAT_VERSIONstays 11,VERSIONstays0.0.1.dev0, no new error, event, or domain model. M3's agreed tripwire for logic leaking upward is a second migration; there is none.Endpoints (nine)
POST /projects/{id}/sources/imagesPOST /projects/{id}/sources/videoextraction_fps, 201GET /projects/{id}/sourcesGET /sources/{id}POST /sources/{id}/ingest-jobsLocationGET /sources/{id}/ingest-jobsGET /ingest-jobs/{id}POST /ingest-jobs/{id}/resumeLocationGET /batches/{id}/assetsA collection hangs off its owner; an individually addressable resource does not — nesting
/projects/{p}/sources/{s}/ingest-jobs/{j}puts four segments in front of an id that already identifies one thing. A schema version has no id of its own, which is why it stays nested all the way down.Four decisions
Uploads are staged content-addressed (
server/uploads.py). The kernel registers a source by path; an HTTP client has bytes. Parts land under<workspace>/uploads/<digest>/, where the digest is sha-256 over the sortedname:sha256lines — one rule for one clip and for fifty stills. The same files under the same names stage to the same path, soSourceService's(kind, path, extraction_fps)idempotency survives the trip to HTTP and a repeated upload returns the same source. Nothing is buffered whole: Starlette spools past 1 MiB and the module streamsupload.filein chunks (upload.read()must never appear there).safe_namereduces every client filename to its last component — the path-traversal guard, with its own tests.Upload only — no server-side-path registration. It would hand every token holder an arbitrary-directory read, and the CLI and MCP already hold real paths. The dividend:
FileNotFoundError/NotADirectoryErrorbecome unreachable, and those are plain Python exceptions with no place inERROR_RULES— so this PR adds nothing to the error table.gt=0on theextraction_fpsform field closes the last one, a bareValueErrorthat would have been a 500.One background worker (
server/runner.py), aThreadPoolExecutor(max_workers=1)onapp.state, drained in the lifespan before the workspace closes. One worker serializes writers against a single-writer store; what that buys the reader is #80's WAL, andtest_polling_is_answered_while_another_writer_holds_the_workspaceproves it by parking a secondWorkspaceServicemid-write and asserting the poll is still answered — with committed state, not the writer's.Refusals split by when they can be known. Anything the request can refuse is refused synchronously (404 unknown source, 422 blank batch name, 409 non-resumable job); everything after the launch is reported on the job.
batch_idtargeting is deliberately not on the launch body — batches have no endpoints until #29, and leaving it out means the launch has no failure mode that produces no job row.Two findings worth recording
IngestStartneeds no_the_domain_accepts_itvalidator, and the contrast is the useful part.LabelClassBodyneeds one becauseLabelClassrefuses with a pydanticValidationError— neither aVisionSetErrornor aRequestValidationError, so it reaches the catch-all as a 500. A blank batch name refuses withInvalidName, already inERROR_RULESat 422INVALID_NAME, so the kernel's own refusal arrives correctly and a validator would only restate it less precisely. The first draft had one; the test that expectedVALIDATION_ERRORis what found it.FastAPI emits
Body_register_image_source/Body_register_video_sourcefor multipart bodies, named off the operation id — whichgenerate_unique_id_function=operation_idalready pins to the handler name, so they are as stable as every other component.Ledger
openapi.json34 KB → 73 KB.tests/architecture/test_tracked_file_sizes.pycaps a tracked file at 200 KB; not close, and deliberately not allowlisted.python-multipart>=0.0.9declared in[project].dependencies. It was already installed here — transitively, viamcp. An upload surface resting on somebody else's dependency tree breaks the day that tree changes.tests/server/test_sources.py(23),test_ingest.py(14),test_batches.py(8),test_uploads.py(21), plus kernel tests forenqueue/resumable/BatchService.assets. Still noconftest.pyanywhere;tests/server/_runner.pyjoins_api.py/_probe.py/_openapi.pyas plain helpers.RecordingRunner.wait()joining the worker's future; sequencing isGatedRunner's events — thetests/kernel/test_concurrency.pydoctrine. The threaded module ran 15 consecutive times, 0 failures before shipping.Fixture.job_inintests/kernel/test_ingest_service.pynow walks topendingviaenqueueinstead of planting it.runningis the only planted state left, and it is the one no operation leaves behind.api.md(202 + the launch/poll block, multipart and size expectations, the paths block),ingest.md(the enqueue/resume split, a new "Over HTTP" section),sources.md(upload staging),workspaces.md(uploads/in the layout),batches.md(assets()).MCP parity (#24's standing rule)
Tools this capability implies, for #35 to implement:
register_image_source,register_video_source,list_sources,get_source,start_ingest,get_ingest_job,list_ingest_jobs,resume_ingest,list_batch_assets.Recorded now because it does not carry over cleanly: MCP takes a local path, not multipart. The staging area exists because HTTP has no paths; an agent on the same machine calls
register_imagesdirectly, andvisionset/server/uploads.pymust not grow an MCP caller.Checks