feat(job): create job definition only after successful upload#687
Conversation
Job-definition creation was not atomic: the dataset was created, datapoints were uploaded, and the definition was persisted unconditionally - even when some datapoints failed - then a FailedUploadException was raised afterwards. A partial upload therefore left behind an incomplete-but-existing definition, and the only documented retry (dataset.add_datapoints) both dropped into a lower-level API than the one used to create the job and, if the caller instead re-ran create_*_job_definition, spun up a second dataset and definition. Model creation as an explicit state machine (JobDefinitionCreationMachine): create dataset -> upload datapoints -> persist definition, reaching the persist step only once the upload lands within a configurable failure tolerance. On failure no definition is created; the raised FailedUploadException carries the machine so exception.retry() re-uploads only the failed datapoints into the SAME dataset and finishes creating the definition - no duplicate dataset or definition, and recovery speaks the same language as creation. failure_tolerance is the fraction of a job's datapoints allowed to fail while still creating the definition (0.0 = strict default, 1.0 = create regardless). Set it globally via rapidata_config.upload.failureTolerance / RAPIDATA_failureTolerance, or per call via the new failure_tolerance argument. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
Review: atomic job-definition creation via state machineSolid design for the core problem (no more phantom incomplete definitions, symmetric Bug:
|
Addresses review feedback: failure_tolerance=1.0 was documented as "always create regardless of failures", but the at-least-one-success floor meant a 100%-failed upload still raised. That floor is intentional - a definition over an empty dataset has nothing to label and would be exactly the useless remote artifact this change prevents - so the behavior stays and the docstrings/docs now state the caveat explicitly, with a test covering the edge case. Also pass job_definition=None explicitly on the FAILED path (it was always None there anyway). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
) Concurrent local-file uploads could crash with `OSError: [Errno 24] Too many open files` on a common `ulimit -n 1024`. The dominant descriptor consumer is the on-disk upload cache: `FanoutCache(shards=128)` opens ~384 file descriptors at construction and grows to ~640 under the default 25-worker pool (measured), so a single default-config process already sits close to a 1024 limit before open files and HTTP connections are counted. Lower the default `cacheShards` 128 -> 32. Cache shards only reduce SQLite write-lock contention, and cache writes are tiny key->filename strings written after the network upload — so at the default 25 workers, 32 shards (>= worker count) costs nothing measurable while cutting cache descriptors from ~640 to ~317, keeping even several concurrent processes under 1024. `cacheShards` stays decoupled from `maxWorkers`: deriving it from the worker count would reshuffle the key->shard mapping and silently invalidate the persistent cross-run upload cache whenever workers changed. Also make the failure discoverable: `FailedUploadException` now detects descriptor-exhaustion failures (EMFILE, or the message text as a fallback) and appends a hint pointing at `RAPIDATA_cacheShards` / `RAPIDATA_maxWorkers` / `ulimit -n` and the config docs, and both are documented under a new "Too many open files" section. The bounded worker pool already respected the post-#687 atomic upload-then-persist ordering (it lives entirely within the state machine's UPLOAD_DATAPOINTS step); this change tunes the cache below it and does not touch that control flow. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: lino <68745352+LinoGiger@users.noreply.github.com>
Problem
Job-definition creation wasn't atomic. In
_create_general_job_definitionthe flow was: create dataset → upload datapoints → persist the definition unconditionally → then raiseFailedUploadExceptionif anything failed. So a partial upload left behind an incomplete-but-existing definition. Worse, the natural retry — re-callingcreate_*_job_definition(...)— spun up a second dataset and definition, and the documented alternative (e.dataset.add_datapoints(e.failed_uploads)) dropped the caller into a lower-level API than the one they used to create the job. Reported from an SDK 3.16.5 Compare integration.Change
Model creation as an explicit state machine (
JobDefinitionCreationMachine): create dataset → upload datapoints → persist definition, reaching the persist step only once the upload lands within a configurable failure tolerance.update_datasetalready did for revisions.)FailedUploadExceptioncarries the machine, soexception.retry()re-uploads only the failed datapoints into the same dataset and finishes creating the definition — returns aRapidataJobDefinition, re-raises if still failing. No duplicate dataset/definition; recovery speaks the same language as creation.dataset.add_datapointsstays as an advanced escape hatch.failure_tolerance— fraction of a job's datapoints allowed to fail while still creating the definition (0.0strict default …1.0create regardless). Global viarapidata_config.upload.failureTolerance/RAPIDATA_failureTolerance, or per-call via the newfailure_toleranceargument on everycreate_*_job_definition.Behavior change (intentional)
Previously a partial failure still yielded a runnable definition with the datapoints that succeeded. Now the default (
failure_tolerance=0.0) yields no definition until you retry. Setfailure_toleranceabove0.0(or1.0) to keep the old "proceed with what uploaded" behavior. Docs (error_handling.md) rewritten to lead withretry()+ tolerance.Known limitation
There's no dataset-delete endpoint, so a failed run still leaves an orphaned dataset (never an orphaned definition), and a retry reuses it. Fully removing the orphan needs backend support (idempotency key / dataset delete) — out of scope here.
Tests
New
tests/rapidata_client/job/test_job_creation_state_machine.py: full-success creates once; failure within tolerance still creates; failure beyond tolerance raises with no definition;retry()reuses the same dataset and persists exactly one definition. Full suite green (11 passed),pyrightclean,blackclean, docs build.🔗 Session: https://node-218fd5bc.poseidon.rapidata.internal/
🤖 Generated with Claude Code