Skip to content

driver-sql: dropping an orphaned hash-shadow-carried index leaves its generated column behind forever, contradicting the cleanup contract isHashShadowColumn states #13056

Description

@os-zhuang

Found while implementing #13015 (the differ half of the shadow-carried UNIQUE story); pre-existing and untouched by that PR. No open duplicate: the repo-scoped issue list was fetched over 6 pages and grepped locally for shadow / __hash / drop_index / dropIndexIfExists / orphan across 342 open issues (the listing ended at 360 items, so coverage is complete). 19 hits, none about this; shadow matching #13015 itself is the positive control for that reading.

The stated contract

schema-drift.ts's isHashShadowColumn docblock explains why the orphan-COLUMN pass must never propose dropping a #11627 hash shadow, and states what happens instead, verbatim:

Matched by SUFFIX rather than by a registry of known names, deliberately: the differ runs against a database whose metadata it is comparing to, and a shadow whose declared index has since been removed must still be recognised as driver-owned (it is then cleaned up by the index's own removal path, not by a blind column drop).

The measurement

There is no such cleanup on the index's removal path.

So when metadata stops declaring a shadow-carried UNIQUE, diffManagedIndexes step 3 reports the index as an orphan with a drop_index op, the apply drops the index, and the VARBINARY(32) STORED generated column survives with nothing keying it. The orphan-COLUMN pass then declines to report it forever, exactly as designed — pointing at a cleanup that does not exist.

Why it is not merely cosmetic, and why it is also not urgent

Not urgent: nothing is mis-enforced. The column is derived, holds no user data, and if the same declaration ever returns, #13015's hashShadowColumnState now finds the survivor and re-keys or re-generates it correctly.

Not cosmetic either: a STORED generated column is recomputed and written on every INSERT and on every UPDATE touching its source columns, and indexed or not it occupies the row. A table that has accumulated retired declarations pays that on every write, permanently, with no pass in the system willing to report it — the shape the ledger disciplines exist to prevent, reached by a docblock that says the cleanup is someone else's job.

Direction, not prescribing

The obvious move is to have the index removal path drop hashShadowColumnFor(indexName) when it exists and is a generated column. It needs a decision rather than a patch, because dropIndexIfExists has a second caller: replace_unique_index drops legacy index names, and whether a legacy name's shadow should go with it is a different question from whether an orphan's should. An alternative is to leave dropIndexIfExists alone and give the drop_index op its own shadow-aware step, which keeps the legacy path byte-identical.

Either way the ordering matters — drop the index first, then the column — and the foreign guard #13015 added (refuse a non-generated column of that name rather than drop it) is the precedent for how ownership should be established before any column is removed.

Topic label left to triage.

Generated by Claude Code · found while implementing #13015

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions