Skip to content

feat(db): partition hot tables and add shard routing layer - #112

Open
Darkdruce wants to merge 1 commit into
GruftNet:mainfrom
Darkdruce:feat/partition-sharding-strategy
Open

feat(db): partition hot tables and add shard routing layer#112
Darkdruce wants to merge 1 commit into
GruftNet:mainfrom
Darkdruce:feat/partition-sharding-strategy

Conversation

@Darkdruce

Copy link
Copy Markdown
Contributor

Closes #97

What

Implements the partitioning half of #97 as working code, and the sharding half as a routing layer that is live but single-shard.

Partitioningbets and blockchain_events become monthly declarative range partitions, keyed on placed_at and ledger_close_time. Postgres cannot ALTER a table into a partitioned one, so each is rebuilt: rename aside, create parent, copy, drop. A DEFAULT catch-all absorbs stray historical rows so the copy cannot fail.

src/db/partitionManager.ts handles the rolling window: create months ahead, detach-then-drop past retention, and countDefaultPartitionRows as an alerting hook — a non-zero count means maintenance stopped running and writes are landing outside every defined range.

Sharding — a 4096-slot space, Redis-Cluster style: keys hash to slots, and slots (not keys) are assigned to shards, so rebalancing moves a known set of rows without rehashing everything. market_id is the shard key because a market and its bets must co-locate, otherwise the hot read path becomes a cross-shard join.

ShardRouter is the interface services depend on instead of a Pool. With the seeded single-shard row it degenerates to a plain connection pool, so it is safe to route everything through it now — adding a second shard later becomes a deployment change rather than a rewrite of every query site. Map validation rejects gaps and overlaps at startup, since a gap silently drops writes and an overlap sends one key to two shards.

The correctness issue this surfaced

Partitioning silently weakens the tx_hash dedup guard. A partitioned table's UNIQUE constraint must contain the partition key, so UNIQUE (tx_hash, placed_at) only holds within a partition. A write retried with a re-defaulted placed_at lands in a different partition and is accepted — on a betting ledger, a double-credited bet. Confirmed empirically, not theoretical.

Fixed with tx_hash_registry: unpartitioned, so its primary key is genuinely global, trigger-enforced on insert, backfilled from copied rows, and pruned in step with partition retention so it does not grow without bound.

This is worth a look during review. It adds a trigger to the write path. The alternative is making placed_at deterministic at the service layer so retries land in the same partition — cleaner, but it depends on services that are still blueprint stubs.

Pre-existing bugs found

The migration chain cannot currently run from scratch. Two unrelated breakages:

  1. 1723000000000_add-token-columns destructured its argument as a pg.Pool and called pool.query, but node-pg-migrate passes a MigrationBuilder. It threw pool.query is not a function and has never applied. Fixed here — it is ordered before this migration and its columns had to survive the table rebuild. The SQL is unchanged.

  2. 1719360000000_add-cleanup-tables alters notification_jobs, but no migration ever creates it — the table exists only in db/schema.sql. Not fixed here, as where that table should be defined is a judgment call. It was worked around locally to verify this branch. Worth its own issue.

Verification

Against a real Postgres 15 (docker-compose.test.yml), full chain from an empty schema:

  • migrations apply cleanly; bets reports relkind = 'p'
  • seeded rows preserved and routed to correct monthly partitions; a 2019 row lands in default as designed
  • EXPLAIN on a one-month range scans a single partition — pruning works
  • cross-partition duplicate tx_hash rejected by the registry guard; legitimate inserts unaffected
  • down round-trips: data intact, global bets_tx_hash_key restored

tests/db/partition.test.ts adds 26 unit tests, all passing. tsc --noEmit and eslint clean.

Full jest reports 24 failing suites — all pre-existing Cannot find module against blueprint services that do not exist yet (StellarService, cache.service, …). None reference this code.

🤖 Generated with Claude Code

Convert bets and blockchain_events to monthly declarative range
partitions, keyed on placed_at and ledger_close_time. Postgres cannot
ALTER a table into a partitioned one, so each is rebuilt via
rename-aside, create parent, copy, drop.

Add a slot-based sharding layer: markets hash to one of 4096 slots and
slots are assigned to shards, so rebalancing moves a known set of rows
without rehashing. market_id is the shard key so a market and its bets
stay co-located. Ships with a single seeded shard, making the router a
plain pool today and a deployment change later.

Restore global tx_hash uniqueness via tx_hash_registry. A partitioned
table's UNIQUE constraint must include the partition key, so
UNIQUE (tx_hash, placed_at) only holds within a partition — a retry
that re-defaults placed_at lands elsewhere and is accepted, which
double-credits a bet. The registry is unpartitioned so its primary key
is genuinely global, and is pruned alongside partition retention.

Also fix 1723_add-token-columns, which called pool.query on the
MigrationBuilder that node-pg-migrate passes and so had never applied.
It is ordered before this migration and its columns must survive the
table rebuild.

Verified against Postgres 15: migration chain applies, rows route to
the correct partitions, pruning scans one partition, the cross-partition
duplicate is rejected, and down round-trips with data intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

Build Partition & Sharding Strategy

1 participant