Skip to content

feat: unblock multi-state loading (address uniqueness keyed on region) - #7

Merged
keonik merged 4 commits into
mainfrom
feat/multi-state-hash
Sep 12, 2026
Merged

feat: unblock multi-state loading (address uniqueness keyed on region)#7
keonik merged 4 commits into
mainfrom
feat/multi-state-hash

Conversation

@keonik

@keonik keonik commented Sep 11, 2026

Copy link
Copy Markdown
Owner

ohio_addresses.hash is HouseNumber|Street|Unit|City|Postcode and the column
was UNIQUE. There is no state anywhere in it.

With a postcode present that mostly does not matter — US ZIPs are
state-specific, so they disentangle the key. But ingestion requires only a house
number and a street, and county GeoJSON extracts frequently ship without a ZIP
column. For those rows the key collapses to house number, street, unit and
city, and city names collide across states constantly.

The failure is silent and looks like success. A colliding row hits the
unique constraint, the importer counts it as a duplicate, and a load that
quietly discarded half a state reports N records imported, M duplicates skipped and completes green.

under the old key only OH survived; Illinois was counted as a duplicate

That is a test, pinning the old behaviour so the regression stays on the
record.

The key, not the hash

Recomputing every hash would rewrite all ~6M rows. Keying on (hash, region)
is one index build and means exactly the same thing.

Migration 23 also handles what that key needs to actually hold:

  • Backfills region to 'OH' where NULL or empty — NULLs are distinct
    in a unique index, so those rows could otherwise duplicate without limit.
  • Upper-cases existing values'oh' and 'OH' are different keys, so a
    lowercase upload would reintroduce the collision. Ingest normalises too.
  • CHECK (region <> '')NOT NULL does not stop an empty string, and
    '' would put every stateless row into one shared bucket.
  • Indexes region — without it, filtering by state is unusable.

No DEFAULT on region, deliberately: a default would silently label a future
non-Ohio import as Ohio, which is the same quiet corruption being fixed.

The other half: a state filter

?state=OH, case-insensitive. Filtering by county alone is ambiguous once two
states exist — roughly two dozen states have a Franklin County. A test seeds
Franklin County in both OH and IN and asserts the unfiltered count is 3 and the
Ohio-filtered count is 2.

Rollback

The down migration can legitimately fail once a second state is loaded: no
single-column unique constraint can hold rows sharing a hash across regions.
That is the rollback refusing to destroy data, and the fix is to roll forward.

Cost and verification

The unique index build takes a SHARE lock — reads continue, writes wait; on ~6M
rows expect tens of seconds. Writes here are imports, which are deliberate admin
actions.

Applied twice: onto a database carrying the real chain (with NULL and lowercase
regions seeded, both normalised correctly) and 1→23 from scratch. Verified after
migration that the old constraint is gone, the new key is present, region is
NOT NULL, and the same address in two states keeps both rows. Full suite green
against four probe databases.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S2nhUJ4Zdsxf7cP9DapsY3

Gray Fay and others added 4 commits September 11, 2026 11:16
…load

ohio_addresses.hash is HouseNumber|Street|Unit|City|Postcode and the column was
UNIQUE. No state anywhere in it.

With a postcode present that mostly did not matter -- US ZIPs are
state-specific, so they disentangle the key -- but ingestion requires only a
house number and a street, and county GeoJSON extracts frequently ship without
a ZIP column. For those rows the key collapses to house number, street, unit
and city, and city names collide across states constantly: Springfield,
Columbus, Franklin.

The failure was silent and looked like success. A colliding row hit the unique
constraint, the importer counted it as a duplicate, and a load that quietly
discarded half a state reported "N records imported, M duplicates skipped" and
completed green. A test now pins that old behaviour so the regression stays on
the record.

Migration 23 changes the uniqueness key rather than the hash contents.
Recomputing every hash would rewrite all ~6M rows; keying on (hash, region) is
one index build and means the same thing. It also:

  - backfills region to 'OH' where NULL or empty, since NULLs are distinct in a
    unique index and those rows could otherwise duplicate without limit
  - upper-cases existing values, because 'oh' and 'OH' are different keys and a
    lowercase upload would reintroduce the collision
  - adds CHECK (region <> ''), since NOT NULL does not stop an empty string and
    '' would put every stateless row in one shared bucket
  - indexes region, without which filtering by state is unusable

No DEFAULT on region, deliberately: a default would silently label a future
non-Ohio import as Ohio, which is the same quiet corruption being fixed.

The other half is a state filter. Filtering by county alone is ambiguous once
two states exist, so ?state=OH now narrows it, case-insensitively.

The down migration can legitimately fail once a second state is loaded -- no
single-column unique constraint can hold rows that share a hash across regions.
That is the rollback refusing to destroy data.

COST: the unique index build takes a SHARE lock, so reads continue and writes
wait; on ~6M rows expect tens of seconds. Writes here are imports, which are
deliberate admin actions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2nhUJ4Zdsxf7cP9DapsY3
**The migration blocked reads, not just writes.** ALTER TABLE ... SET NOT NULL
takes ACCESS EXCLUSIVE -- verified, not assumed -- and a lock once taken is held
until commit. Taking it before the index build meant every /search and /geocode
query blocked for the whole build, while migrations run asynchronously against a
server already serving. The two index builds (SHARE) now come first and
everything needing ACCESS EXCLUSIVE is last, holding it only for its own
validation scan. The cost note said "reads continue"; it was wrong.

**The down migration could leave the table with no uniqueness at all.** Its own
comment says the final ADD CONSTRAINT is expected to fail once two states are
loaded -- but the composite key and the CHECK were already dropped by then, so
every ON CONFLICT (hash, region) would fail with 42P10 and deduplication would
be off entirely. Now one transaction, so the documented "correct outcome" is a
clean no-op rather than a half-dropped schema.

**ON CONFLICT (hash, region) shipped ahead of the index it needs.** Migrations
run asynchronously while upload endpoints are live, so a deploy-window import
failed every batch and marked the dataset failed -- indistinguishable from a
corrupt file. This is the third time this branch family has hit that window, so
it is now a named guard: database.RequireSchemaVersion, called at the top of the
import path. Read paths can degrade; a write path that needs a constraint
cannot.

**The legacy loader was updated for the new conflict target but not the new
invariants.** It truncated "Ohio" to "Oh" -- a different uniqueness bucket from
"OH" and invisible to a region filter -- and produced "" when an extract had no
state, which the region CHECK rejects on every row. Since that error text is not
"duplicate key", the loop logged per row and returned 0 inserted with a nil
error: a total failure reported as success. It now normalises through a real
state-code check.

**Geocoding ignored the state in the query.** The parser extracts one and
searchByComponents discarded it, so "100 Main St, Springfield, IL" matched the
Ohio row on street and city and returned it at full confidence -- the exact
collision this branch exists to prevent, on the endpoint that matters most.
State is now an anchor on every tier: what relaxes as tiers widen is
street-level detail, never the state.

**An uploaded state code was upper-cased but never validated.** "HO" for "OH"
now creates a separate dedup bucket, so the import succeeds green and a
re-upload under the correct code inserts a second full copy. Validated at all
three upload entry points.

**DROP CONSTRAINT IF EXISTS failed open.** If the production constraint carries
another name it is a silent no-op, leaving a live global unique hash beside the
new key while the migration logs success -- the same silent loss being fixed.
It now asserts against pg_constraint and errors.

**"Every row is Ohio" was an assumption.** Now self-verifying: the migration
refuses to stamp OH onto blank regions if any other state is present. Verified
by seeding an IL row and watching it refuse. The two full-table UPDATEs are also
one pass now rather than two rewrites of ~6M rows.

**?state=%20 and ?state=Ohio returned an empty page with no explanation.**
Trimmed and validated at the edge, 400 with an example.

Test fixtures carry the region CHECK and schema_migrations, so the cases those
guard fail in the suite rather than only in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2nhUJ4Zdsxf7cP9DapsY3
They arrived on separate branches and first met here. Both extend the same
hand-numbered placeholder sequence -- the bbox consumes four positions -- and a
mistake there binds the wrong value to the wrong column, which returns
plausible rows from the wrong place rather than an error.

Seeds the same coordinates in two states so only the state filter can separate
them, then checks bbox alone, bbox plus state, and bbox plus state plus a text
query, each adding placeholders of its own.

The fixture also gains the generated fts column and its indexes: without them a
text query combined with a territory filter could not run at all, which is why
this combination had never been exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@keonik
keonik merged commit 77314f6 into main Sep 12, 2026
3 checks passed
@keonik
keonik deleted the feat/multi-state-hash branch September 12, 2026 05:25
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