Skip to content

Document how to retire a whole table in the online-migration rules (PP-4653) - #3745

Open
dbernstein wants to merge 1 commit into
mainfrom
chore/document-online-migration-table-drops
Open

dbernstein wants to merge 1 commit into
mainfrom
chore/document-online-migration-table-drops

Conversation

@dbernstein

Copy link
Copy Markdown
Contributor

Description

Extends the "Online migrations (backwards compatibility)" section of CLAUDE.md to cover retiring a whole table. The section already explained the two-release split for columns (deferred() for reads, server_default for writes), but said nothing about tables, and the two rules that matter there are not obvious from the column rules.

Adds:

  • A relationship() is a read. SQLAlchemy loads a relationship whenever its parent is deleted — to cascade the delete (mapper.cascade_iterator) or to null the child's foreign key (dependency.presort_deletes). Leaving Parent.children mapped does not stop using the child table even when no application code touches the attribute.
  • A fresh schema comes from the models, not the migrations. InstanceInitializationScript.initialize_database_schema calls SessionManager.initialize_schema (metadata.create_all) and stamps alembic head, so deleting a model class removes its table from newly initialized databases immediately — and the backwards-compatibility gate builds its "current" schema exactly this way.

Together these place the release boundary between the relationship and the model, which the new text spells out as a numbered sequence, plus two follow-on details: removing a relationship can make surviving rows block parent deletes (the relationship was what cascaded by hand), and the model's tests have to go in release 1 because the gate runs N-1's test suite against the new schema.

Documentation only — no code changes.

Motivation and Context

JIRA (PP-4653)

This is the guidance whose absence let #3521 reach review with a table drop that would have broken N-1 webservers during a rolling deploy. #3520 followed the documented rule — "stop using the object in the code, but leave it in the schema" — and still left three live ORM reads behind, because the rule as written only talks about columns.

Writing it down so the next table retirement starts from the right split instead of rediscovering it from a red backwards-compatibility gate.

How Has This Been Tested?

pre-commit clean (including the PyMarkdown hook). No code changes, so no test run applies.

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

🤖 Generated with Claude Code

The online-migration section explained the two-release split for columns
(deferred, server_default) but not for tables, and the missing guidance is what
let PR #3521 reach review with a drop that would have broken N-1 webservers.

Two traps were undocumented: a relationship() is a read, because SQLAlchemy
loads it on every parent delete to cascade or to null the child's foreign key;
and a fresh database's schema comes from create_all over the models rather than
from replaying migrations, so deleting a model class removes its table from new
installs regardless of the migration. Together they put the release boundary
between the relationship and the model, not around the model.

Also note the two follow-on details that bit us: removing the relationship makes
surviving rows block parent deletes, and N-1's test suite must not contain tests
that write to the doomed table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dbernstein's task in 4m 24s —— View job


Summary

Good, well-sourced addition — I verified the two load-bearing claims against the tree and both hold: InstanceInitializationScript.initialize_database_schema does call SessionManager.initialize_schema (Base.metadata.create_all) and then command.stamp(..., "head") (src/palace/manager/scripts/initialization.py:71-86), and the gate description matches docker/ci/test_backwards_compatibility.sh. My one substantive concern is that the prescribed release-1 step ("keep the model class") is not what the repo actually did for the one table currently mid-retirement — customlist_sharedlibraries was reduced to a bare Table in Base.metadata, and the comment there points readers straight at this section, so the two will contradict each other once this merges.

Details

CLAUDE.md:187-192

The release-1 step here says to keep the mapped model class, but the repo's only worked example of this procedure does the opposite: customlist_sharedlibrary (src/palace/manager/sqlalchemy/model/customlist.py:390-395) deleted the model class in release 1 and left behind a bare Table(...) on Base.metadata, with a comment that explicitly says "see the online-migration section of CLAUDE.md" — i.e. it cites the text this PR is writing. The bare Table satisfies bullet 2's create_all requirement while removing the mapper entirely, so no lazy load, cascade, or stray session.query(Model) can reach the doomed table; keeping the class leaves every one of those paths available and makes release 1's safety depend on a reviewer noticing them, which is the failure mode bullet 1 exists to prevent. Worth reworking the numbered steps (and the "Splitting at the model instead" sentence on 191-192, which conflates deleting the class with removing the table from metadata) around the form already in the tree:

1. **Release 1:** delete the model class and its relationships, and leave a bare `Table(...)`
   registered on `Base.metadata` so the table is still in fresh schemas (worked example:
   `customlist_sharedlibrary` in `src/palace/manager/sqlalchemy/model/customlist.py`).
2. **Release 2:** remove the `Table` definition and drop the table in a migration.

circulation/CLAUDE.md

Lines 187 to 192 in 4e2be49

1. **Release 1:** remove the relationships and their `back_populates`, and delete the tests that exercise the
model. **Keep the model class**, so the table still exists in fresh schemas.
2. **Release 2:** remove the model class and drop the table in a migration.
Splitting at the model instead — release 1 deletes the class, release 2 drops the table — fails, because
release 1 already removes the table from new installs while N-1 still maps the relationships.

Minor: CLAUDE.md:199-200

The justification "its ACCESS EXCLUSIVE lock is uncontended on a table nothing reads" contradicts the premise established four bullets earlier: a release-1 migration runs online while N-1 is still serving traffic with the relationships mapped, so N-1 still SELECTs the child table on every session.delete(parent). TRUNCATE therefore has to queue behind any in-flight N-1 transaction that touched the table and blocks new access while it waits. The practical exposure is small (parent deletes only), but as written the rationale invites someone to skip a lock_timeout on a busy table — suggest dropping the "uncontended" clause and keeping only the WAL argument, or qualifying it as "contended only by N-1's parent-delete path."

circulation/CLAUDE.md

Lines 199 to 200 in 4e2be49

in the release-1 migration (right when the rows are dead data — `TRUNCATE` avoids the row-level WAL of a
large `DELETE`, and its `ACCESS EXCLUSIVE` lock is uncontended on a table nothing reads) or give the

Minor: CLAUDE.md:202-203

"Delete the model's tests in release 1 too" is broader than the gate requires and would cost real coverage if followed literally. The gate selects pytest -m db, and that marker is auto-applied only to tests using the db fixture (tests/conftest.py:64-65); migration tests use alembic_runner instead, replay history up to a revision where the table still exists, and legitimately keep writing to it — tests/migration/conftest.py:166-175 inserts into customlist_sharedlibraries by raw SQL for test_20260901_912c566f3383_add_shared_locally_to_customlists.py. Deleting that fixture in release 1 would drop the backfill migration's test for no benefit. Suggest scoping the instruction, e.g. "delete the db-marked tests that write rows to the table; tests/migration/ tests are exempt — they replay migration history, where the table still exists."

circulation/CLAUDE.md

Lines 202 to 203 in 4e2be49

- **Delete the model's tests in release 1 too.** The gate runs N-1's *test suite* against the new schema, so
tests that build rows in the doomed table fail in release 2 even though no application code would.


  • Read the diff and surrounding CLAUDE.md context
  • Verify the SQLAlchemy claims (relationship loads on delete, cascade/presort)
  • Verify InstanceInitializationScript.initialize_database_schema / SessionManager.initialize_schema
  • Verify the backwards-compatibility gate description
  • Post review

Review only — no files changed.
· chore/document-online-migration-table-drops

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The documentation should not merge until the TRUNCATE guidance accounts for live N-1 traffic and its potential to stall an online deployment.

Findings

  1. P1 TRUNCATE Lock Can Contend
  2. P2 Relationship Reads Are Conditional

Summary

This PR expands the online-migration guidance with a two-release procedure for retiring complete tables.

  • Explains why ORM relationships can preserve reads of a retiring table.
  • Documents that fresh databases derive their schema from current model metadata.
  • Specifies when to remove relationships, models, migrations, and associated tests.
  • The overall release split matches the compatibility gate, but the TRUNCATE recommendation overlooks live N-1 traffic and the relationship-loading explanation needs qualification.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A["N-1 webservers remain active<br/>relationships still mapped"] --> B["Release 1 migration runs"]
  B --> C["Release 1 code removes relationships<br/>but retains model/table"]
  C --> D["Release 1 becomes N-1"]
  D --> E["Release 2 removes model<br/>and drops table"]
  B -. "TRUNCATE requires<br/>ACCESS EXCLUSIVE" .-> A
Loading

Reviews (1) · Last reviewed commit: "Document how to retire a whole table in ..."

Comment thread CLAUDE.md
Comment on lines +199 to +201
in the release-1 migration (right when the rows are dead data — `TRUNCATE` avoids the row-level WAL of a
large `DELETE`, and its `ACCESS EXCLUSIVE` lock is uncontended on a table nothing reads) or give the
foreign key an `ON DELETE` clause. A FK that already declares `ON DELETE CASCADE` needs neither.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 TRUNCATE Lock Can Contend

The release-1 migration runs while N-1 webservers are still active, and those servers retain the relationships that this section says query the child table during parent deletion. Therefore, TRUNCATE may wait behind existing transactions or block those requests because it requires an ACCESS EXCLUSIVE lock. Describing that lock as “uncontended” could lead maintainers to turn an online migration into a deployment stall; this guidance should account for N-1 traffic and offer a lock-safe cleanup strategy.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread CLAUDE.md
Comment on lines +173 to +179
- **A `relationship()` is a read.** SQLAlchemy loads a relationship whenever its parent is deleted — to
cascade the delete (`mapper.cascade_iterator`), or to null the child's foreign key
(`dependency.presort_deletes`). So leaving `Parent.children` mapped does **not** stop using the child
table, even when no application code ever touches the attribute: every `session.delete(parent)` still
SELECTs from it. Release 1 has to delete the `relationship()` definitions themselves, and the matching
`back_populates` on the other side — retiring the code that *used* them is not enough.
- **A fresh schema is built from the models, not by replaying migrations.**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Relationship Reads Are Conditional

The statement that SQLAlchemy loads a relationship whenever its parent is deleted is too broad. This repository has relationships configured with passive_deletes=True, where database-side deletion can avoid loading unloaded children. Please narrow the explanation to relationships whose cascade or nulling behavior requires ORM participation; otherwise, maintainers may make unnecessary changes when retiring a table.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.70%. Comparing base (7e44a87) to head (4e2be49).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3745      +/-   ##
==========================================
- Coverage   93.70%   93.70%   -0.01%     
==========================================
  Files         510      510              
  Lines       46487    46487              
  Branches     6313     6313              
==========================================
- Hits        43562    43561       -1     
- Misses       1891     1892       +1     
  Partials     1034     1034              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

1 participant