Skip to content

feat(data-index): per-upload data tables, safe re-upload, delete clean-up - #298

Merged
psaesha merged 7 commits into
devfrom
feature/data-index-table-names
Nov 14, 2026
Merged

psaesha merged 7 commits into
devfrom
feature/data-index-table-names

Conversation

@psaesha

@psaesha psaesha commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Description

Every tabular file is copied into a table in data_db, which previews, charts and the data API read. Three problems came from how that was done:

  • Two resources could share one table. Names were built from the resource name and dataset title, up to 110 characters. Postgres keeps only the first 63 bytes of a name, so the unique part at the end was usually cut off. Two resources with long, similar names then used the same table, and re-uploading one replaced the other's data. The other resource's preview, charts and data API then showed the wrong numbers.
  • Re-uploads dropped the old table before the new one was ready. For a few seconds readers saw empty or half-written data. If the new file failed, the old data was lost for good.
  • Deleting a resource or dataset never removed its table, so the data database kept growing.

What changes:

  • Each upload gets its own table, named from its own row id: rdt_<32 hex>, 36 characters. Names can't collide and don't change when something is renamed.
  • A re-upload builds the new table completely, then switches the resource over in one database commit, and only then drops the old table. Readers see either the complete old data or the complete new data. If anything fails, the old data stays and the half-built table is dropped.
  • The newest file wins: if two uploads of one resource overlap, the slower one checks before switching whether a newer file has been saved since, and backs off. The download and the served data never disagree.
  • Deletes clean up. Deleting a resource or dataset, from the API or the admin, drops its tables once the delete is saved. A cancelled delete keeps them.
  • A one-off command, migrate_data_table_names, moves existing tables:
    • It renames short old names in place.
    • It rebuilds long ones from each resource's own file, since they may already hold another resource's data.
    • It lists tables nothing uses. These are dropped only from a report someone reviewed.
    • It's a dry run by default. The runbook is in api/utils/data_indexing_architecture.md.

No schema change and no migration file: only the generated table name changes. Old and new names work side by side, and rolling back the code is safe.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality): the migration command
  • Documentation update
  • Test improvements

Changes Made

  • api/utils/data_indexing.py: index_resource_data now builds the table, switches in one commit (resource row locked, stored file re-checked), confirms the switch and cleans up. It refuses to run inside an outer transaction. The old create_table_for_resource is removed.
  • api/utils/data_tables.py, api/utils/data_table_names.py: the helpers for each step, and the naming rule.
  • api/models/Resource.py: a new ResourceDataTable row is named rdt_<own id>.
  • api/signals/data_table_signals.py, registered in api/signals/__init__.py: drops a row's table after its delete commits. It keeps an old table another row still shares.
  • api/management/commands/update_dataindex.py: no longer wraps each resource in a transaction. One failing column used to silently roll the whole switch back.
  • api/management/commands/migrate_data_table_names.py + api/utils/data_table_migration.py: the migration command.
  • tests/data_index/: tests run against a real Postgres when TEST_DATA_DB_URL is set (tests/test_settings.py, pytest.ini marker data_db).
  • .github/workflows/django-tests.yml: a postgres:16 service, plus REQUIRE_DATA_DB_TESTS=1, so CI fails instead of quietly skipping those tests.
  • .github/workflows/sdk-tests.yml: pinned to ubuntu-24.04. ubuntu-latest (26.04) has no Python 3.9, which failed the SDK job on fix(data-api): read tables whose column names contain a percent sign #295.
  • api/utils/data_indexing_architecture.md (new), with a link from docs/dataset_data_api.md.

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes. With a local Postgres 16: tests/data_index, tests/schema/test_resource_permissions.py and tests/test_data_indexing_filters.py give 133 passed. Every new test was seen failing first for the expected reason.
  • Manual: the layer-5 journey tests/journeys/data_index/reupload-and-delete.py is written but not run yet. It needs a running backend and a token.

Test Instructions

  1. Start a test Postgres and set the URL:
    docker run -d --name dataspace-test-data-db -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:16
    export TEST_DATA_DB_URL=postgres://postgres:postgres@localhost:55432/postgres
    
  2. Run pytest tests/data_index. Without the URL these tests are skipped; with REQUIRE_DATA_DB_TESTS=1 and no URL the run fails.
  3. After deploying to dev:
    • re-upload a CSV while polling GET /api/resources/<id>/data/; every read is the complete old or the complete new file;
    • delete the resource; its table is gone.
  4. Migration:
    • On a copy of production, run python manage.py migrate_data_table_names --report dry.json.
    • Review the report: counts, shared tables, rows needing a manual fix, unverified tables, orphans.
    • Then follow the runbook in the architecture doc.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code. Each phase was also reviewed separately, and the whole diff once more at the end.
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • black and isort --profile black on the changed files. mypy (with the Django plugin) has no errors in the changed api/ files.
  • Database migrations are included (if applicable): none needed

Additional Notes

Known and left for later (marked TODO in the code and the architecture doc):

  • A failed re-upload still reports success. This was already true: the upload mutations ignore the indexing result. Now the old data stays served while the download serves the new file. The direction, pending a product discussion, is to enforce the database's limits at upload (for example, column names within 255 characters) and to flag problem columns where people add column descriptions.
  • Two column headers that differ only after 63 bytes become one column, so that file never gets a table. Hindi headers hit this at about 21 characters. This predates this PR.

Rollout: deploy, then run the migration command off-peak as described in the runbook: dry run, review, --apply until it prints "nothing to do", and orphans dropped only from a reviewed report.

🤖 Generated with Claude Code

Table names were built from the resource name and dataset title, and
Postgres keeps only the first 63 bytes of a name, so two resources with long
similar names shared one table and a re-upload of one replaced the other's
data. Re-uploads also dropped the old table before the new one existed, so
readers saw empty or half-written data and a failed re-upload lost it.

Each upload now gets a table named rdt_<row id>. index_resource_data builds
the new table in one data-database transaction, then switches the resource
over in one main-database commit (resource row locked, stored file
re-checked so the newest file wins, column rows rebuilt with errors
escaping) and confirms the new row exists; anything short of that drops
this build's table and keeps the old data. A post_delete receiver on
ResourceDataTable drops the old table after commit, which also frees
storage on resource, dataset and admin deletes; it leaves legacy tables
two rows still share. update_dataindex no longer wraps each call in a
transaction, and the flow refuses to run inside one.

Tests run against a real Postgres when TEST_DATA_DB_URL is set; CI adds a
postgres service and fails instead of skipping them.

Plan: plans/claude/data-index-table-names.md (Phase 1)
Existing tables still carry title-based names, and two resources can share
one (Postgres cut both names to the same 63 bytes), so one of them serves
the other's data. This one-off command moves them to rdt_<row id> names.

Short old names can't have collided and are renamed in place, holding the
resource row and waiting at most 2 s for readers. Long ones are rebuilt from
each resource's own file through the normal upload flow, oldest first and
with no read-time limit; the shared table is dropped once its last row has
moved. A long table whose file is missing or can no longer become a table
is renamed and marked "unverified" in its comment if no other row uses it,
and every later report lists it until the resource is re-uploaded. A rerun
after a crash finishes half-done renames.

It is a dry run by default. The dry run checks each stored file, so its
report shows what --apply will do, which tables are shared and which rows
need a person. Tables no row uses are listed with size and comment. They
are dropped only with --apply --drop-orphans --from-report <reviewed
report>, and only if still unused at that moment. The command refuses to
run when the data database looks like the main one. Every row's outcome is
logged as it happens, and an in-progress report is written before any
change.

The Layer 5 journey (re-upload while polling, racing re-uploads, delete) is
added for on-demand runs.

Plan: plans/claude/data-index-table-names.md (Phase 2)
Covers per-upload table naming, the build -> switch -> drop-after-commit
flow, the delete receiver and its shared-table guard, the readers, the
migrate_data_table_names command with its runbook and old naming schemes,
the Postgres test setup, the failure model, and known limitations.
SDK: no. The data API doc links to it.

Plan: plans/claude/data-index-table-names.md (Phase 4)
Final QC found that stopping a command (Ctrl+C) while the old table's
after-commit drop waited for a reader made the flow's clean-up drop the
new table, though the resource already pointed at it. The clean-up now
asks the database first: it drops the build's table only if its row isn't
saved, and leaves the table for the orphan audit when it can't tell. That
also covers a commit that went through but reported an error.

The runbook now says a row that fails twice the same way needs a person.

Plan: plans/claude/data-index-table-names.md (final QC)
Two headers that differ only after Postgres's 63-byte cut become one
column, so the file never gets a table. This bug predates per-upload
tables and is noted, not fixed, by user decision.
A re-upload whose rows can't be loaded still reports success, so the
download serves the new file while previews, charts and the data API serve
the old one. Marked pending a product decision: enforce the database's
limits at upload and flag problem columns in the column-description UI.
ubuntu-latest is now Ubuntu 26.04, where setup-python has no Python 3.9
build. The 3.9 job failed at setup and fail-fast cancelled 3.10 and 3.11
(seen on #295). The SDK still declares 3.9 support, so pin the runner
instead of dropping it.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@psaesha
psaesha merged commit d5ab75a into dev Nov 14, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants