Type: Forward-engineered Status: Implemented 2026-05-04, hardened 2026-05-06 (Sprint OOM Protection + Sprint RLM cleanup). Hexagonal scope: Application (naming) + Infrastructure (collector + registry + retention + lifecycle)
Recent updates (2026-05-06):
cleanup_raw_orphanstask: dropsraw.*tables abandoned acrossresource_identitymigrations (CKAN re-scrape generates new UUID, old physical name has nocdrow pointing). Beat schedule every 6h,max_drops=50. Safety net: pre-loads identities referenced bymart_definitions.sql_definition(parseslive_table('id')andlive_tables_by_pattern('id::*')with regex) and excludes them from drop candidates — protects mart sources. After incident on 2026-05-06 morning whereescuelas_argentinamart broke after orphan sweep dropped its source table.cleanup_raw_orphansdoes double-check before drop: between SELECT candidate and UPDATE, re-verifies the table is STILL missing ininformation_schema.tables. Prevents false positives caused by concurrent DROP+CREATE in_to_sql_safe schema_mismatch_recreate— Postgresinformation_schemais NOT MVCC-snapshotted, so brief DROP-pre-CREATE windows look like "missing" to the orphan detector. After incident: 5,578 false-positive "Table missing" marks in a single sweep, recovered manually.- OOM protection layer (Sprint OOM):
_probe_download_size(url)HTTP HEAD pre-check: ifContent-Length > cap_per_format, markpolicy_too_largewithout spawning worker. Caps configurable via env:OPENARG_MAX_DOWNLOAD_ZIP_BYTES=200MB,_XLSX_BYTES=100MB,_XLS_BYTES=80MB,_CSV_BYTES=500MB,_JSON_BYTES=100MB, default 200MB.- Size-based routing: 50MB <
Content-Length< cap → reroute toworker-collector-heavy(1.5GB memory limit) instead of standard collector (400MB). Configurable viaOPENARG_HEAVY_ROUTE_BYTES=50MB. - Browser User-Agent +
Accept-Language: es-ARheaders in HEAD/GET to bypass403 Forbiddenfrom portals that block default httpx UA (confirmeddatos.cultura.gob.ar).
worker-collector-heavymemory limit raised from 800MB → 1.5GB. Concurrency 4. After repeated SIGKILL on Argentine government ZIP shapefiles (300-400MB compressed expanding to 1+GB on parse).- Magic-byte format detector:
_detect_format_from_bytes(path)peeks first 16 bytes.PK\x03\x04→ xlsx;\xd0\xcf\x11\xe0→ xls;<!DOCTYPE/<html→ html_as_data (skip);,/;/\tin line 1 → csv. Used beforepd.read_excelto avoid "Excel file format cannot be determined" errors when portals serve CSV/HTML with.xlsextension.
Historically, every materialization landed in public.cache_<portal>_<slug>__<hash> with no versioning, no PK, and no lineage columns. Re-ingest overwrote silently; there was no way to tell when a dataset was last refreshed; the same physical name carried any number of upstream-shape variations.
The raw layer is the medallion's bottom tier:
- Schema: dedicated
rawschema (separate frompublic) so legacy code that scanspublicis isolated. - Versioning: every successful ingest produces a new
raw.<portal>__<slug>__<discrim>__v<N>table. Old versions stay until retention drops them. - Lineage: every row carries
_source_url,_source_file_hash,_parser_version,_collector_version. Server-side_ingested_at TIMESTAMPTZ DEFAULT NOW()and_ingest_row_id BIGSERIAL PRIMARY KEYclose the deduplication gap. - Registry:
raw_table_versionsis the source of truth for "which table backs which logical resource at which version".
Created by mig 0039 with CREATE SCHEMA IF NOT EXISTS raw. Tables created by the collector follow the naming <portal>__<slug>__<discrim>__v<N> (see §3).
| Column | Type | Notes |
|---|---|---|
resource_identity |
TEXT | {portal}::{source_id} (matches catalog_resources). PK part 1. |
version |
INTEGER | Monotonic per resource. PK part 2. |
schema_name |
TEXT | Default 'raw'. |
table_name |
TEXT | Bare (<portal>__<slug>__<discrim>__v<N>). |
row_count, size_bytes |
BIGINT | Reported by collector at write time. |
source_url, source_file_hash |
TEXT | Lineage. |
parser_version, collector_version |
TEXT | Bumped when those components change. |
created_at |
TIMESTAMPTZ | Default NOW(). |
superseded_at |
TIMESTAMPTZ | Set when version N+1 lands. NULL for the live version. |
UNIQUE INDEX on (schema_name, table_name) — defends against accidental same-name collisions across resources.
app.application.catalog.physical_namer.RawPhysicalNamer:
- Format:
<portal>__<slug>__<8-char blake2b hash>__v<N>. - Identity-stable: same
(portal, source_id)→ samestem(everything except__v<N>). Bumping versions only swaps the suffix. - Charset
[a-z0-9_]; Postgres 63-char limit enforced (slug truncated last). - Leading-digit slugs are prefixed with
t_.
raw_table_name(portal, source_id, *, version, slug_hint) and raw_table_qualified(...) are convenience helpers.
_resolve_collect_destination(engine, dataset_id, *, portal, source_id, title) returns _CollectDestination(schema, bare_name, version, resource_identity):
- When
OPENARG_USE_RAW_LAYER=1: bumps the version via_resolve_raw_table_for_dataset()and returns araw-schema destination. - When unset/
0: returns the legacycache_<portal>_<slug>__<hash>destination in schemapublic(bit-for-bit unchanged).
The flag is the single rollback knob. Switching it back to 0 restarts producing legacy cache_* tables; in-flight raw tables are unaffected.
_resolve_raw_table_for_dataset() acquires a transaction-scoped pg_advisory_xact_lock(_lock_key("raw_table_version_bump", resource_identity)) before reading MAX(version). Two collectors materializing the same dataset serialize on this lock; both get distinct versions.
_inject_raw_metadata_columns(df, *, source_url, source_file_hash, parser_version, collector_version) adds _source_url, _source_file_hash, _parser_version, _collector_version columns to the DataFrame before to_sql(). Lineage columns are recognised by the auto-typer (which skips them) and dropped before contract validation.
_ingest_row_id and _ingested_at are table-level (added by Postgres, not the DataFrame): BIGSERIAL PRIMARY KEY and TIMESTAMPTZ DEFAULT NOW() respectively.
_register_raw_version() UPSERTs into raw_table_versions with ON CONFLICT (resource_identity, version) DO NOTHING (idempotent). It also sets superseded_at = NOW() on prior versions of the same resource. The call is part of the same transaction-protected critical section as the catalog pointer update — registration + catalog UPDATE happen together; if either fails, the auto-promote to staging is skipped.
openarg.retain_raw_versions(keep_last=3, dry_run=False) (in infrastructure/celery/tasks/ops_fixes.py):
- For each
resource_identitywith >keep_lastversions, list the older ones. _record_cache_drop(table_name=qualified, reason='retain_raw_versions')for audit.DROP TABLEandDELETE FROM raw_table_versions.
dry_run=True reports candidates without executing. Recommended schedule: weekly via Celery beat once the raw layer is stable.
Sister task openarg.cleanup_empty_raw_tables (Sprint Disk Bloat) targets a
distinct failure mode: tables that landed with row_count=0 but received many
ALTER TABLE ADD COLUMN calls during _to_sql_safe schema_mismatch_recreate,
producing tens of MB of page bloat with zero data. These are in
raw_table_versions (not orphans, so cleanup_raw_orphans skips them) but are
useless and waste disk.
Selection criteria (all must hold):
schema_name='raw'ANDCOALESCE(row_count, 0) = 0created_at < NOW() - min_age_hours(default 24h, avoids in-flight races)pg_total_relation_size > min_size_mb(default 100MB — only meaningful disk wins)- A newer version with rows exists for the same
resource_identity, ORsuperseded_at IS NOT NULL
Beat schedule: weekly Sunday 04:00 with dry_run=False, max_drops=50, min_size_mb=100.
Default dry_run=True for ad-hoc invocation.
Lessons learned during introduction (2026-05-09): an initial audit using
pg_stat_user_tables.n_live_tup falsely identified ~10 GB of "empty" tables
that actually had 6M rows each. n_live_tup is the optimizer's stale estimate,
not the row count. Always use raw_table_versions.row_count as the source
of truth — that's what _register_raw_version writes in the same critical
section as the table creation.
- FR-001: Every materialization to
raw.*MUST come with an entry inraw_table_versions. The two writes happen in the same critical section. - FR-002:
_register_raw_versionMUST mark older versions assuperseded_at = NOW()so consumers can identify the live version. - FR-003: Version selection MUST be serialized per
resource_identityviapg_advisory_xact_lockto prevent races. - FR-004:
OPENARG_USE_RAW_LAYERMUST be the single rollback knob; setting it to0reverts the destination resolver to the legacycache_*behaviour with zero other changes. - FR-005:
_apply_cached_outcomeMUST validateraw_schemaagainst a closed allow-list ({"raw"}) before composing it into a qualified name. - FR-006:
Auto-promotion to staging MUST NOT fire whenOBSOLETE 2026-05-04 — the staging layer was removed (mig 0042). Replaced by FR-007._register_raw_versionfailed - FR-007: When a raw landing transitions to
readyAND_register_raw_versionsucceeded,_apply_cached_outcomeMUST enqueuerefresh_martfor every mart whosesource_portalsincludes the landed portal. The dispatch uses a per-marttask_idso multiple landings within the same window collapse to one refresh. - FR-008: When a raw version is superseded (a newer version of the same
resource_identitylands), anycached_datasetsrow instatus='ready'that points to the superseded table name MUST be deleted in the same transaction. Without this, the orphan ready rows accumulate as the catalog re-processes datasets, and discovery surfaces staletable_names.
- Multi-file ZIP / multi-sheet Excel expansion (lives in
application/expander/; not yet wired into the raw layer — see DEBT-015-004). - Backfill of pre-Fase 1.5
cache_*tables. Operators runtruncate cached_datasets + cache_* dropand the next collector run produces raw tables from scratch.
- DEBT-017-001: The advisory lock key in
_resolve_raw_table_for_datasetis derived fromresource_identityvia_lock_key()(a hash). Two unrelated resources whose hashes collide would serialize unnecessarily — low probability with a 64-bit hash, but worth measuring. - DEBT-017-002:
retain_raw_versionsruns DROP unconditionally on superseded tables. A future enhancement is "soak before drop" — keep the table for N days aftersuperseded_atso audit replays work.