Uh oh!
There was an error while loading. Please reload this page.
fix(data ingest): reclaim the staged source copy after a clean success (#166) - #167
Merged
Conversation
#166) The CLI streams a full copy of the dataset into SharedRoot/.tracebloc-staging/<table>, and the in-cluster ingestor COPIES (shutil.copy, never moves) those files into the final table dir. So after a successful `tracebloc data ingest` BOTH copies lived on the shared PVC — the staged source was only ever removed by `data delete` or an `--overwrite` re-ingest. Every successful push of a file-bearing dataset silently doubled PVC usage, eventually surfacing as "no space left on device" on a later ingest with no signal as to why. Add push.CleanStaging: a best-effort rm -rf of ONLY StagedPrefix(table) via the same ephemeral stage-identity pod Teardown already uses (it runs as the uid that wrote the staging files, so the rm works by ownership on hostPath and CSI alike). It never touches the final table dir or the MySQL table. runDataIngest calls it after a CLEAN success only (classifyPushOutcome == "succeeded"), so it never fires on --detach (the Job is still reading the source), completed_with_failures, or any failure. A failed reclaim logs a warning and does not fail the ingest. Robustness (from pre-PR adversarial review, all low-severity): - the reclaim's wait+exec is bounded by StagingCleanupTimeout (45s) so a stuck/unschedulable cleanup pod can't tack the full pod-ready timeout onto a command the user already saw succeed; - the cleanup pod is created under a detached context so a parent-ctx cancel in the create window can't orphan a server-committed pod; - with --output-json the result object is emitted BEFORE the reclaim, so scripted consumers get their result at ingest-completion latency. Longer term the cleaner fix is server-side (ingestor move-not-copy / remove SRC after a verified load) — will file a data-ingestors follow-up. Closes#166. Refs #67. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saadqbal
approved these changes
Jul 7, 2026
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Jul 7, 2026
saadqbal added a commit
to tracebloc/data-ingestors
that referenced
this pull request
Jul 21, 2026
* feat(ingest): emit per-column feature_stats on the global-metadata channel (#360) At ingest, CSVIngestor now accumulates the five additive sufficient statistics per numeric feature column — count, sum, sum_sq, min, max — during the existing per-chunk cast pass (no second read) and emits them as meta_data['feature_stats']. The backend folds these into global mean/std/min/max at combine time for federated normalization (backend#1037); only aggregates cross the cluster boundary, never raw values. Producer side of backend#1053 / #924 G4a, slice 1. - csv_ingestor.py: _accumulate_feature_stats folded into the INT and FLOAT/DECIMAL cast branches; feature_stats() finalizer. Excludes the label/target, row-id, and annotation columns — the regression target is bucketed precisely so its raw min/max must never leak. Nulls dropped; all-null columns omitted; sum_sq computed in float64 to avoid Int64 overflow. - base.py: format-agnostic _collect_run_metadata() hook (default {}), merged into file_options just before send_ingest_summary, alongside the existing text_profile injection. - tests/test_feature_stats.py: accumulation correctness across chunks, feature-only exclusion, null handling, and emit wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(ingest): nest feature_stats under the attributes namespace (#360) Design review on backend#1037 finalized the dataset_meta shape: `schema` stays a plain {column: dtype} map and every per-column extra lives under `attributes.feature_stats`. Update the emit to match: - CSVIngestor._collect_run_metadata now returns {"attributes": {"feature_stats": …}} instead of top-level feature_stats. - base.py: extract _apply_run_metadata(), which merges INTO the shared `attributes` namespace rather than replacing it — so the per-category attributes slice (text/image facts) can coexist with feature_stats. - tests: assert the nested shape and the attributes-preserving merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): emit target-column stats for regression-class tasks (#360) Federated target normalization for time-series forecasting (and the other regression-class tasks) was blocked: slice 1 excluded the label column from feature_stats, so the backend had no target stats to fold into global mean/std/min/max (backend#1037). Include the label/target column in feature_stats for REGRESSION_CLASS_CATEGORIES (time_series_forecasting, time_to_event_prediction, tabular_regression); keep excluding it for classification (its value is a class, not a numeric feature). Row-id and annotation columns stay excluded for every category. Scope: sufficient stats only, which unblocks Standard/MinMax/MaxAbs global scaling. Robust/Quantile/Power need medians/quantiles/λ that aren't derivable from these aggregates — a separate follow-up. Raw per-row target values remain governed by label_policy="bucket"; only the additive aggregates ship, and the target's min/max disclose its two extremes (an accepted trade, for backend#1053 sign-off). - csv_ingestor.py: build _feature_stats_excluded from REGRESSION_CLASS_CATEGORIES (reuses the single source of truth in cli.conventions; no cycle — cli/__init__ is import-free). - tests: split exclusion coverage into id/annotation-always, label-excluded-for- classification, and target-included-for-regression-class (parametrized over the three regression-class categories). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): emit auto-detected scalar attributes for combine-time alignment (#360) Extends the attributes tier the backend folds into the merged dataset (G4a / backend#1037) and the edge consumes (tracebloc-engine#455/#459), with the facts the ingestor can derive from the data — no new user-facing config: - Categorical union vocab: the VARCHAR/CHAR/TEXT cast pass accumulates each categorical feature column's distinct value-set (capped at _MAX_CATEGORICAL_CARDINALITY; free-text/id-like columns dropped), emitted as attributes.feature_stats[col].categories — the stable cross-client vocab the edge encodes against. Label/id/annotation columns excluded. - Temporal (time_series_forecasting): the timestamp cast pass captures the timezone (when tz-aware) and a bounded in-order sample; sampling_frequency is inferred via pd.infer_freq. Both omitted when unavailable. - Image resolution: emitted as [height, width] from the run's uniform target_size (the resolution validator enforces it) — no image read. - Text encoding: 'utf-8' for NLP categories (non-UTF-8 text is rejected at validation, so this is the true canonical value). Resolution/encoding live in a format-gated BaseIngestor._scalar_attribute_metadata so manifest-based image/text datasets emit them regardless of CSV/JSON ingestor; CSVIngestor folds them in via super() alongside feature_stats + temporal. Tests: categorical vocab (sorted/dedup/cross-chunk/exclusions/cap/nulls, coexists with numeric); temporal timezone + frequency (aware/naive, gated to time-series); image resolution [h,w]; text encoding. 26 pass; full suite green (603 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): emit user-declared color_mode/channels/bit_depth for vision alignment (#360) Completes the image attribute tier. color_mode is user-provided in file_options for vision use cases (not auto-detected — PIL modes like RGBA/CMYK aren't in the contract enum) and restricted to RGB/grayscale, the values the backend G4a contract and the edge preprocessor accept. channels derives from color_mode (RGB->3, grayscale->1); bit_depth (8/16) is optional. - constants.canonical_color_mode() canonicalizes user input (rgb/grayscale/grey/ gray/l, case-insensitive) to 'RGB'/'grayscale', with COLOR_MODE_CHANNELS. - BaseIngestor._scalar_attribute_metadata emits color_mode + derived channels + bit_depth from file_options for image datasets; a non-canonical value is skipped (never shipped) rather than crashing. - conventions.resolve() bridges top-level color_mode/bit_depth into file_options (spec > top-level precedence, like target_size) and validates them at config time — a bad value fails fast with a clear message. Tests: emission (RGB/grayscale channels, bit_depth, invalid skipped, absent); conventions bridge + canonicalization + fail-fast validation. Full suite green (1504 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): optional enriched schema with role:target, gated (#360) Adds an enriched {col: {dtype, role}} shape to the schema emitted on the global-metadata channel, behind the EMIT_ENRICHED_SCHEMA gate (default off). The framework `label` column carries role:"target" for supervised tasks so combine-time alignment (backend#1037) identifies the prediction target from the contract instead of inferring it by elimination. Breaking wire-format change (schema values: type strings -> objects), so it is version-gated and must be cut over with backend#1037; default runs are unchanged. Physical-table shape per the #361 discussion: keys/types are what get_table_schema reflects, target key is `label` (distinct from feature_stats, which keys the target by its original name). - config.py: EMIT_ENRICHED_SCHEMA env-backed bool (default off). - base.py: _schema_payload() builds flat or enriched; _TARGET_COLUMN="label"; role:"target" only when label_column is set (supervised). - api/client.py: schema param typed Dict[str, Any]. - tests/test_enriched_schema.py. Slice 1b of #360. dtype canonicalization (via #349) and the remaining descriptors are follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(ingest): key regression-class target feature_stats under `label` (#360) The target is stored in the framework-standard `label` column and flagged role:"target" in the enriched schema (PR #370), but feature_stats keyed it by its original CSV name — so the two channels disagreed on the target key and a consumer had to guess (tracebloc-engine's TSF scaler_y identified it by elimination, which is ambiguous when an extra non-feature numeric stat exists). Re-key the regression-class target's feature_stats entry to `label` at finalize so schema.role and feature_stats agree; the engine now looks it up directly (tracebloc-engine#460). Feature columns keep their own names. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(schema): canonical dtype in the enriched schema (#360 / #349) The enriched schema emitted raw storage types as dtype (VARCHAR(255), DECIMAL(10,2)), so combine-time comparison (backend#1037) would read a mere width/size difference — VARCHAR(255) vs VARCHAR(100), or INT vs BIGINT — as a type divergence and spuriously block. Add schema_inference.canonical_dtype (housed with the #349 type-inference rules): strips (...) parametrisation and folds storage-size variants to one logical family (int/float/bool/date/datetime/time/string/binary), unknown types passing through lower-cased. _schema_payload now emits the canonical dtype. - schema_inference.py: _CANONICAL_DTYPE map + canonical_dtype(). - base.py: _schema_payload uses canonical_dtype. - tests: canonical_dtype mapping + width/size folding; enriched schema emits canonical dtypes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(schema): uploader-declared unit/ordinal descriptors in the enriched schema (#360) unit and ordinal can't be inferred from the data, so they're declared per column in ingest.yaml and merged into the enriched schema descriptors. This activates backend#1037's combine-time descriptor checks — most importantly the `unit` mismatch guard, so an income-in-USD dataset isn't silently merged with an income-in-EUR one. - ingest.v1.json: optional top-level `columns` map {col: {unit?, ordinal?}}, additionalProperties:false on the descriptor (typos rejected). - conventions.py: bridge top-level `columns` onto file_options.column_descriptors (same spec-wins precedence as the target_size / time_column bridges). - base.py: _schema_payload merges unit/ordinal for columns present in the schema (declared entries for unknown columns ignored); enriched branch only. - tests: ingest.v1.json accept/reject, conventions bridge, _schema_payload merge. Declared by CSV column name — matches feature columns; the target (keyed `label` in the physical schema) isn't a typical unit-bearing column. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(schema): bool_encoding + null_encoding descriptors in the enriched schema (#360) Complete the enriched schema's per-column descriptors with the two value encodings, so backend#1037's cross-dataset WARN checks aren't inert: - null_encoding: "null" on every column — the ingestor maps every recognized NA token to SQL NULL. - bool_encoding: "1/0" on bool-dtype columns — MySQL BOOLEAN stores 1/0. Both describe the INGESTED (physical) table and are uniform by construction, so they're derived in _schema_payload from the physical schema — no cast-pass observation, no csv_ingestor changes. A dataset ingested under a different convention would surface as a mismatch at combine time. - base.py: _NULL_ENCODING / _BOOL_ENCODING constants; _schema_payload stamps null_encoding per column and bool_encoding on bool columns. - tests: encodings present/scoped correctly; existing descriptor assertions updated. Enriched schema descriptor set now complete: dtype, role, unit, ordinal, null_encoding, bool_encoding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): address Bugbot review on #367 (categorical exclusion + bit_depth) 1. [High] Categorical vocab exclusion used exact string keys against the raw configured label/id/annotation names, but the accumulation runs on the raw CSV header before label pinning. A header spelled `Label` (config `label`) slipped the exclusion and leaked the column's raw values into attributes.feature_stats. Match via resolve_column (the #340 case-/ whitespace-insensitive rule) so the intended columns are always excluded. 2. [Low] _scalar_attribute_metadata copied any int bit_depth into emitted attributes; a value set via spec.file_options / modality spec bypasses conventions.resolve()'s 8/16 gate. Re-check bit_depth in (8, 16) before emitting, mirroring color_mode canonicalisation. - tests: label excluded from vocab under a case-mismatched header; bit_depth=12 dropped while a valid color_mode still emits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): min-count suppression for rare categorical values (#360) Addresses the #367 review's rare-category re-identification concern: a category present for very few records (a rare diagnosis / demographic) was emitted verbatim in the alignment vocab. - The categorical accumulator now tracks per-value occurrence COUNTS (Counter, vectorised value_counts per chunk) instead of presence-only. - categorical_vocab() drops values seen fewer than CATEGORICAL_MIN_COUNT times; a column left empty is omitted. - New CATEGORICAL_MIN_COUNT config (env-backed int, default 1 = keep all / no suppression). Default is a no-op because raising it drops rare values from the union vocab, which the edge must then handle as OOV at encode time — that trade needs backend privacy sign-off + engine OOV handling (backend#1079/#1053), so it ships as an opt-in knob to flip post-sign-off. - tests: suppression at min_count=2 (incl. across chunks), default keeps all, column omitted when all values suppressed; config default + numeric-override coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): enriched schema on by default + emit text/survival alignment facts (#360) G5: EMIT_ENRICHED_SCHEMA now defaults ON — backend#1037 (schema pass-through, role-preserving) and engine#460 (reads role from schema) depend on the enriched {col: {dtype, role}} shape. An explicit falsey env/override still forces the legacy flat map for a pre-cutover backend. G9/G10: emit the previously-inert alignment facts so the backend's BLOCK guards go live and edge text handling has inputs — - text: uploader-declared `language` + `normalization` (alongside `encoding`), - survival: uploader-declared `time_unit` + `event_indicator` {event, censored}, bridged from config in conventions.resolve() (validated) and re-checked against the contract shapes before emission. Also scopes text facts to the text categories only (not embeddings, which the contract scopes to its own positive_definition) — fixes a latent ingest-time rejection where `encoding` was emitted for embeddings datasets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): one canonical schema on the wire + emit embeddings positive_definition (#360) G8: drop the internal, label-stripped `schema` copy from the shipped meta_data — the canonical schema already ships as the top-level `schema` arg (enriched, target-bearing). file_options keeps its copy for the in-run validators; only the duplicate on the wire is removed, so downstream reads one unambiguous schema. positive_definition: emit the embeddings-specific alignment fact (what defines a positive pair, uploader-declared) for the embeddings category, bridged from config in conventions.resolve(). Closes the last `expected` contract field that was never produced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): resolve feature_stats exclusion + target re-key case-insensitively (#360) Bugbot/review (#361): _accumulate_feature_stats compared the configured label/id/annotation names against actual CSV headers with exact membership, and the regression-class target re-key required an exact label_column match. Headers can differ from the configured names by case/whitespace, and validators resolve them via resolve_column — so a drifted header (config "Label" vs header "label", or "Target"/"rowid" vs "target"/"RowId") slipped the exclusion (leaking a label/ id into feature_stats) or skipped the re-key (leaving the target under the CSV name, breaking alignment with schema role:"target" / backend feature_stats[label]). Both now use resolve_column, matching the categorical-exclusion path. Regression tests cover a case-drifted classification label and a case-drifted regression target + id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): resolve column_descriptors case-insensitively + map target descriptor to label (#360) Bugbot (#361, commit b3cd028): the enriched-schema column_descriptors merge used exact key membership (col not in enriched), so a descriptor keyed with case/ whitespace drift from the reflected column was dropped, and a descriptor for the target (declared by its source name, e.g. "demand_mw") never attached because the physical schema keys the target as `label`. Both now use resolve_column, and a descriptor matching the configured label_column maps onto `label`. Also fixes the CI failure: the earlier regression test drifted unique_id_column, which the unique_id validation rejects at ingest (exact match, before accumulation) — so a drifted id can never reach the stats to pollute them. The test now drifts only the target (the reachable case); the id matches its header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): resolve exclusion sets once, then exact-match — no over-match (#360) Bugbot (#361, commit e9df11f): case-insensitive resolve_column exclusion could OVER-match — a distinct feature whose header only case-matches a role name (label `target` vs feature `Target`) was dropped. Now the configured label/id/annotation names are resolved to their actual header spellings once (in _validate_csv, #340), then feature_stats/categorical exclusion is by EXACT membership against that resolved set — so a drifted role header is still excluded while a case-variant feature is kept. The base.py descriptor merge resolves to a physical column first, mapping to `label` only when the declared name is the target's source name. Regression tests: a feature case-matching the label name is kept; a feature descriptor case-matching the label's source name is not misrouted to `label`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ingest): flip default data_id strategy uuid → content_hash (#350) #225 shipped the salted content-hash data_id dark in v0.5.7 — the default data_id_strategy stayed `uuid` everywhere, so the retry-duplication cure was inert. Privacy sign-off is approved (backend#818) and content_hash has soaked opt-in through v0.6.0/v0.7.x, so flip the default now. - Default data_id_strategy `uuid` → `content_hash` at all four constructor sites (base.py, record_processor.py, csv/json_ingestor.py) and in the CLI ResolvedConfig (cli/conventions.py). - Add an explicit `uuid` opt-out branch to resolve(): with the default now content_hash, `strategy: uuid` would otherwise fall through and silently stay content_hash. `column` keeps mapping unique_id_column (which wins over the strategy in RecordProcessor regardless of its value). - Update schema/ingest.v1.json default + description to match. Tests: lock the new default and the uuid opt-out in test_conventions and test_content_hash_data_id; add a process_record content-hash default test. Mocked-DB harnesses that exercise the full ingest() path now stub get_or_create_table_salt (a real DB always returns a salt); process_record- direct harnesses pre-set _table_salt or pin uuid where the id strategy is orthogonal to what they test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): don't silently clobber a feature named "label" when re-keying the regression target (#360 review) feature_stats() re-keys a regression-class target from its CSV column name to the reserved "label" key (so feature_stats["label"] is the target downstream). If a DIFFERENT numeric feature was literally named "label" (allowed — create_table excludes "label" from its reserved set), the pop→assign overwrote that feature's aggregates silently, so combine-time normalization could use the target's stats for the real feature. The target must still own "label" (contract), but now it logs a warning naming the clash instead of clobbering silently. Test added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(e2e): pin uuid on duplicate-row fixtures after the content_hash flip (#350) The image_classification (576 rows = 6 unique ×96) and object_detection (128 = 10 unique) template fixtures repeat the same rows. Under the new content_hash default those collapse to the distinct set via the data_id UNIQUE upsert (content-level dedup, working as designed), breaking the row-per-source-row goldens in the characterization + content-compare harnesses. Pin those two cases to data_id.strategy=uuid so the 1:1 fidelity goldens hold. Every unique-row case keeps the content_hash default, so the full run.main() stack is still exercised with content_hash end to end; the content_hash dedup/retry-reclaim path is covered by e2e/test_database_e2e.py::test_content_hash_retry_reclaims_rows. Verified: full e2e suite (49 tests) green against real MySQL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): strip label/annotation/id columns by resolve_column so a target descriptor can't stick to the source column (#360 review) __init__ removed the label/annotation/unique_id columns from the physical table schema by EXACT key. label_column isn't pinned to the real header until _resolve_label_column runs mid-ingest, so a manifest label_column that drifts in case/whitespace from the header (e.g. "Price" vs "price") missed here and left the target's SOURCE column in the table. It then reflected back into the enriched schema, and _schema_payload attached the uploader's unit/ordinal descriptor to that feature-named column instead of the framework `label` column that carries role:"target" — so the backend's combine-time target descriptor checks silently missed the declared unit/ordinal. Resolve each special column against the schema keys case-/whitespace-insensitively (the #340 rule used everywhere else) before removing it. The target source column is then never physical, so _schema_payload's existing physical-first routing sends the target descriptor to `label` with no #340 regression for genuine features. Test added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(schema): declare the top-level alignment facts so ingest.yaml can set them (#360 review) conventions.resolve() bridges color_mode, bit_depth, language, normalization, time_unit, event_indicator and positive_definition from the TOP LEVEL of the ingest config into file_options, but ingest.v1.json is additionalProperties:false and declared none of them (only `columns`). A submission using those documented fields failed schema validation before resolve() ran, so the alignment facts couldn't be set via the top-level surface at all. Declare the seven keys with constraints matching resolve()'s own validation (bit_depth enum {8,16}; time_unit enum; event_indicator object requiring event+censored; color_mode a permissive string since canonical_color_mode accepts case-insensitive aliases; language/normalization/positive_definition free strings). Tests: the facts validate on their category examples, and bad values (bit_depth 12, event_indicator missing censored) are still rejected at config time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(objdet): resolve annotation by stem so extension-bearing filename works (#379) object_detection ingestion skipped every record (Job exit 9, 0 ingested) when the manifest `filename` column carried the image extension (e.g. `a.jpg`) — the format the objdet-ok parity fixture ships and the template README documents as supported. `_find_src("annotations", filename, ".xml")` kept the recognised `.jpg` extension instead of swapping to `.xml`, so it searched `annotations/a.jpg` (never present; the annotation is `a.xml`). The File Pairing validator pairs image<->XML by on-disk stem, so validation passed while transfer failed. Add a `force_extension` mode to `_find_src` that strips a recognised trailing extension and appends the target extension, and use it at both annotation resolution sites (object_detection factory + annotation_transfer). Both the extension-bearing `a.jpg` and the bare stem `a` now resolve to `a.xml`, matching Path.stem (what File Pairing pairs on) and the documented "with or without extension" contract. Regression tests cover the end-to-end objdet extension-bearing case, the standalone annotation_transfer path, and the _find_src stem/bare/internal-dot edges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): reclaim staged source after verified load (~2x PVC) The ingestor COPIES each file-bearing dataset's staged sidecars from SRC_PATH into the final table dir (DEST_PATH) but never removed the staging copy, so every ingest left two copies on the shared PVC and ~doubled disk usage for image / detection / segmentation datasets (#346). Add file_transfer.reclaim_source(cfg): after a VERIFIED, clean load it rmtree's the SRC_PATH staging tree, replacing the CLI's extra rm -rf pod (tracebloc/cli#167) and closing the leak for the helm-driven path too. BaseIngestor._ingest_with_lock calls it only when dataset_registered is True AND there are no failed records — a partial/failed run keeps its source for retry/inspection, matching the compensating-delete branch that leaves staged files in place. The reclaim runs while the table lock is still held, so it can't race a concurrent ingest of the same table. reclaim_source is heavily guarded + best-effort: it never deletes a dir that IS or CONTAINS the freshly-written table (SRC == DEST, DEST inside SRC, SRC == STORAGE_PATH), never touches the shared root, and swallows filesystem errors so a leftover staging copy can't turn a green ingest red. No per-file shutil.move (that would delete source mid-load, before the load is verified — unsafe). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): make source reclaim opt-in to the staging subtree (Bugbot #381) reclaim_source used a blocklist of dangerous layouts; Bugbot found three holes where a clean load would rmtree the wrong tree: - the helm layout puts SRC_PATH at the user's OWN dataset dir (parent of images:, e.g. /data/shared/cats-dogs), a SIBLING of the table dir — the blocklist only skipped when DEST was INSIDE SRC, so it deleted user data; - guards compared os.path.abspath strings, so a symlinked SRC_PATH (PVC mounts are symlinks) resolved past them and rmtree could hit the real target; - dest_abs.startswith(src_abs + os.sep) degenerates to a "//" prefix when SRC_PATH is "/", so a root SRC slipped the containment check. Replace the blocklist with a positive, opt-in gate: reclaim ONLY a dir that resolves (realpath) to strictly inside STORAGE_PATH/.tracebloc-staging — the isolated SharedRoot/.tracebloc-staging/<table> tree the CLI stage pod writes, provably a throwaway copy. All paths are realpath'd before every check and before rmtree (fixes the symlink bypass); containment uses commonpath component-wise (fixes the "//" root case); a backstop still refuses if the resolved source overlaps the table dir. Net: the CLI-staged path (the #346 target, replacing cli#167's rm -rf pod) still reclaims; a user's mounted data dir in the helm-direct layout is left untouched — no worse than the pre-#346 status quo. Adds tests for the sibling user dir, the symlink escape, a root SRC, and a symlinked storage mount (must still reclaim). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): bind source reclaim to the per-table staging dir (Bugbot #381) The opt-in gate proved SRC_PATH was under STORAGE_PATH/.tracebloc-staging but not that it was THIS table's staging dir, so a stray SRC_PATH pointing at another dataset's staging — or a <table> symlink that realpaths into the tree — could pass and be rmtree'd on the shared PVC. Require the resolved SRC_PATH to equal EXACTLY STORAGE_PATH/.tracebloc-staging/<TABLE_NAME> (the literal per-table path, NOT realpath'd, so a <table> symlink pointing elsewhere fails the equality and is left alone). A blank TABLE_NAME, another table's dir, or a name that escapes the staging subtree all skip. The dest-overlap backstop and best-effort rmtree are unchanged. Adds tests: another table's staging dir, a symlink into another table's staging, and an unset TABLE_NAME. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): make the WHOLE reclaim best-effort, not just rmtree (Bugbot #381) reclaim_source promised best-effort ("a leftover staging copy must never turn a green ingest red") but only shutil.rmtree was guarded. realpath, isdir, the _is_within guards, or a logging call could still raise and propagate through the ungated call in _ingest_with_lock AFTER the dataset is registered — failing a load that already succeeded. Split the guard/decision/delete logic into _reclaim_source and wrap the whole call in reclaim_source with a catch-all that logs (itself guarded, so a broken logger can't propagate either) and returns False. Adds tests: an unexpected guard error and a logging failure are both swallowed without failing the ingest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): resolve filename column case-/whitespace-insensitively end-to-end (#372) The filename column was validated case-/whitespace-insensitively at preflight (IngestableRecordsValidator via the shared resolve_column rule) but read by the exact literal key record.get("filename") at transfer, so a header like `Filename` passed the dry-run and then failed at transfer for every row ("No filename found in record", exit 9) after upload — the #340 class already fixed for the label column, still open for filename. Add BaseIngestor._resolve_filename_key, wired into the ingest loop next to the #340 label resolver: it resolves the file-pointer column once per run via the same resolve_column rule the validators use and copies it onto the canonical `filename` key every downstream read expects, so preflight and transfer agree. Scoped to file-bearing categories; no-op for the common lowercase header, for an image_id-only manifest (a genuinely different column, not a case variant — cli#371), and for tabular datasets carrying an unrelated FileName data column. Sparse JSON whose leading object omits the column is retried on later records. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(objdet): soften Path.stem claim in _find_src force_extension comment Comment-only. `_has_extension` strips only a recognised trailing extension, so the stem matches Path.stem for the jpg/jpeg/png images objdet ships but not for a non-recognised final suffix (e.g. a.tar.gz) — unreachable for objdet, but the "matches Path.stem exactly" wording shouldn't be leaned on. Reword to state the recognised-extension gate, why it's deliberately NOT Path.stem (keeps image.001 whole), and where the two diverge. Addresses Asad's review nit on #380. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ingest): warn loudly when a staged-but-unbindable source isn't reclaimed (#381) Addresses Asad's review: the per-table gate silently couples reclaim to the CLI's staging layout (SRC_PATH == SharedRoot/.tracebloc-staging/<TABLE_NAME>), and nothing here pins that cross-repo contract. If the CLI convention drifts, reclaim quietly no-ops at info level and the #346 ~2x-disk leak returns on a green ingest. - Split the skip log: a source UNDER .tracebloc-staging but not this table's expected dir (a CLI layout drift, or a cross-table SRC_PATH) now logs WARNING, so the #346 leak can't return invisibly on a green ingest. A source OUTSIDE the tree (helm user dir, storage root) stays INFO (a deliberate skip). - Documented the cross-repo coupling at STAGING_DIRNAME and flagged a follow-up for a shared constant / cross-repo integration check. Adds tests: drift under the staging tree warns; a user-dir skip stays quiet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(csv): gate alignment feature_stats/vocab to tabular-family categories feature_stats and the categorical union vocab are combine-time alignment facts (backend#1037) that describe the CSV's cells as FEATURES — only true when the rows are the data (ModalitySpec is_tabular_family). They were accumulated and emitted for EVERY CSV category, so a manifest-style ingest (e.g. keypoint_detection with a Visibility JSON TEXT column) shipped raw cell content off-premise as a "vocab" for no alignment gain. Gate both accumulators on TABULAR_FAMILY_CATEGORIES (the registry's single-source set base.py already uses). Bugbot medium on the #383 release PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ingest): stop shipping the internal column_descriptors bridge in meta_data column_descriptors is bridged onto file_options by config resolution solely so _schema_payload can merge the uploader's unit/ordinal declarations onto the enriched schema's columns — but the send path stripped only the internal "schema" copy, so the raw descriptor map (keyed by CSV source names the backend can't correlate) still shipped in meta_data after its facts were already on the schema. Extract the filter into _meta_data_payload() with a shared _META_DATA_INTERNAL_KEYS set covering both bridges, unit-testable without a full ingest run. Bugbot medium on the #383 release PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge pull request #386 from tracebloc/feat/persist-ingest-task feat(journal): record each run's task in the ingest-run journal * fix(ingest): strip the scalar alignment bridges from meta_data too Second bugbot round on #383, same internal-bridge class #385 closed for schema/column_descriptors: color_mode, bit_depth, language, normalization, time_unit, event_indicator and positive_definition are bridged onto file_options only so _scalar_attribute_metadata can copy them under attributes — the sole channel the backend reads (dataset_validators reads meta_data.attributes). The raw keys still shipped at meta_data top level beside the canonical copies. Extend _META_DATA_INTERNAL_KEYS to cover all bridged facts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge pull request #384 from tracebloc/fix/372-filename-column-exact-lowercase fix(validators): require an exact lowercase "filename" column at preflight (#372, supersedes #382) * fix(objdet): default data_id to uuid — content_hash collapses per-object rows The #350 flip made content_hash the default for every category, but objdet manifests list one row PER OBJECT: duplicate (filename, label) rows are distinct objects (the bundled VisDrone sample has three identical car rows for one image). Those rows produce the same content digest, so the data_id UNIQUE upsert keeps a single stored row — silently under-counting objects and label_counts. Bugbot High on the #383 release PR; the e2e fixtures already had to pin uuid to dodge exactly this. - conventions.resolve: absent data_id block + object_detection => uuid (explicit strategy: content_hash stays honored) - objdet template: pass data_id_strategy="uuid" explicitly - BaseIngestor: warn loudly when objdet runs under content_hash via the direct-constructor path (can't distinguish explicit from default there) Restoring retry idempotency for objdet (row-ordinal-salted hash) is a follow-up, not a default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(objdet): import TaskCategory locally — merge-ref loses the module import develop's copy of test_ingestor_base.py was refactored to function-local TaskCategory imports and has no module-level one, so the PR merge-ref resolves the import block to develop's version and the new test NameErrors (pytest 3.11/3.12 on the PR). Make the test self-contained, matching the file's convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(backfill): metadata builder for pre-cutover datasets (di#360 / backend#1037) (#378) Adds tracebloc_ingestor/metadata_backfill.build_dataset_metadata: recompute the {schema, meta_data} payload for an ALREADY-INGESTED table without re-ingesting rows. Numeric feature_stats come from SQL aggregates, categorical vocab from a bounded GROUP BY. It reuses CSVIngestor._schema_payload / _collect_run_metadata (by injecting SQL-computed accumulators) so the output is byte-identical to a fresh ingest. The API send is intentionally NOT included. Review refinements (#378): - Square in float (DOUBLE) via cast so sum_sq matches the live float64 path and can't overflow BIGINT / drift on INT columns. - LIMIT the categorical GROUP BY to cap+1 so a high-cardinality column isn't fully loaded just to be dropped. - Import _MAX_CATEGORICAL_CARDINALITY from CSVIngestor (no duplicate constant). - Note on CSVIngestor's _feature_stats_acc/_categorical_acc/_schema_payload that metadata_backfill depends on them (rename protection). Tests: unit-cover the SQL-shaping helpers (framework/label exclusion, regression target kept as "label", int/float + Decimal coercion, all-null omission, cardinality cap) with a fake connection, plus SQLite integration tests end-to-end (regression + classification), asserting exact sum_sq. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(backfill): honor the tabular-family alignment gate (#385) in metadata backfill build_dataset_metadata injected the SQL-built accumulators directly into the ingestor, bypassing the accumulation-time gate #385 added on the live cast pass — so a backfill pointed at a manifest-style table (e.g. keypoint with a Visibility TEXT column) could ship raw cell values as vocab that a fresh ingest of the same table would suppress. Bugbot High on the #383 release PR. Honor the ingestor's own _emit_alignment_stats flag (derived from the category in __init__) and skip the table scans entirely for non-tabular categories, so the backfilled payload mirrors a fresh ingest's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(release): bump version to 0.7.5 (#390) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Divya <divyasingh@tracebloc.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Co-authored-by: Lukas Wuttke <lukas@tracebloc.io>
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 freeto 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.
Summary
Fixes #166 (found by the ingest-flow UX audit): after a successful
tracebloc data ingest, the CLI-staged source copy atSharedRoot/.tracebloc-staging/<table>was never reclaimed, so every successful push of a file-bearing dataset silently left ~2× the dataset on the shared PVC until adata delete/--overwrite.Root cause, verified end-to-end: the CLI streams a full copy to the staging prefix; the in-cluster ingestor copies it (
shutil.copy,data-ingestors/.../file_transfer.py:61— never a move) into the final table dir. Both copies then survive; onlypush.Teardown(on delete/overwrite) ever removed the staging one.What changed
push.CleanStaging— best-effortrm -rfof onlyStagedPrefix(table)(never the final table dir, never the MySQL table) via the same ephemeral stage-identity podTeardownuses (owns the uid-65532 staging files → rm works on hostPath + CSI).runDataIngestcalls it after a clean success only (classifyPushOutcome == "succeeded"). It never fires on--detach(Job still reading the source),completed_with_failures, or any failure. A failed reclaim logs a warning and does not fail the ingest (exit stays 0,--output-jsonstatus untouched).Safety (this rm runs on a shared multi-tenant PVC)
A 10-agent adversarial review confirmed the load-bearing properties hold — path is the exact
.tracebloc-staging/<table>dir (no glob, noreg/reg_trainprefix collision),ValidateTableNameruns before staging, and the gate maps to exactlyJobOutcomeSucceeded-with-no-failures. Three low-severity refinements from that review are folded in:StagingCleanupTimeout(45s) so a stuck cleanup pod can't add the full pod-ready timeout to an already-successful command;--output-jsonresult emitted before the reclaim, so scripted consumers aren't delayed by cleanup.Tests
TestCleanStaging_RemovesOnlyStagingPrefix(rm targets only the staging prefix, in the ephemeral stage pod, no leak) andTestCleanStaging_PodCreateFailureReturnsError(surfaced as a non-fatal error).TestClassifyPushOutcomealready pins that only the clean case yields"succeeded", protecting the gate. Fullinternal/push+internal/clisuites green;gofmt/go vetclean.Follow-up
The cleaner long-term fix is server-side (ingestor move-not-copy or
rmitsSRC_PATHafter a verified load), removing the need for the extra pod — I'll file adata-ingestorsticket. This CLI-side reclaim is the immediately shippable fix for existing clusters.Part of the
tracebloc data ingestUX sweep (epic #67). Remaining audit items (silent-wait progress, undisclosed 1h watch cap, kubectl-jargon in errors, flag rejection #77) will follow as separate PRs.Closes#166.
🤖 Generated with Claude Code