diff --git a/apps/api/src/migrations/0006_importers_and_events_perf_indexes.ts b/apps/api/src/migrations/0006_importers_and_events_perf_indexes.ts new file mode 100644 index 0000000..3d0df7b --- /dev/null +++ b/apps/api/src/migrations/0006_importers_and_events_perf_indexes.ts @@ -0,0 +1,48 @@ +import { PoolClient } from 'pg'; + +// #1090 — index that a prior investigation (#257) intended to add but which +// never actually reached this migration runner. +// +// #257's closing PR added `idx_importers_created_at` only to the legacy, +// no-longer-executed `apps/api/migrations/007_hot_path_indexes.sql`. That +// file is read by nothing — `npm run migrate` / `db:migrate` only runs +// `src/migrations/000N_*.ts` via `src/migrations/runner.ts` — and the index +// doesn't exist in `0001_initial_schema.ts` or any other file here. Verified +// by grepping every `src/migrations/*.ts` file and `src/db.ts` for the index +// name: zero hits before this migration. So every database this pipeline has +// ever actually provisioned has been missing it. +// +// idx_importers_created_at supports `GET /importers` for surety_admin +// (`ORDER BY i.created_at DESC` with no WHERE clause, +// routes/importers.ts:189-197). Without it this is a sequential scan + sort +// that grows with the full importers table on every admin list load (#1090). +// See docs/investigations/importers-list-pagination-at-scale.md. +// +// (#1093 also investigated the similarly-unwired `idx_contract_events_raw_gin` +// from #238/005_contract_events_jsonb_gin.sql, and deliberately does NOT +// re-add it here — see docs/investigations/contract-events-gin-index-at-scale.md +// for why: nothing in this codebase ever writes or queries +// contract_events.raw, so the index would add write overhead with no read +// benefit.) +// +// Not using CONCURRENTLY: `src/migrations/runner.ts` wraps every migration's +// `up()` in a single `BEGIN`/`COMMIT`, and `CREATE INDEX CONCURRENTLY` cannot +// run inside a transaction block — Postgres rejects it outright. +// (`0005_scalability_indexes.ts` already uses CONCURRENTLY and would hit +// this same error if actually run through this runner — a pre-existing +// issue in that migration, unrelated to #1090, flagged separately rather +// than fixed here since changing runner.ts's transaction handling affects +// every migration, not just this one.) A plain CREATE INDEX briefly locks +// writers on `importers`, matching the precedent already set by +// 0002_partition_contract_events.ts's own non-concurrent +// `CREATE INDEX ... ON contract_events(...)`. + +export async function up(client: PoolClient): Promise { + await client.query(` + CREATE INDEX IF NOT EXISTS idx_importers_created_at ON importers(created_at DESC); + `); +} + +export async function down(client: PoolClient): Promise { + await client.query(`DROP INDEX IF EXISTS idx_importers_created_at;`); +} diff --git a/apps/api/src/routes/importers.ts b/apps/api/src/routes/importers.ts index 2081c10..0251480 100644 --- a/apps/api/src/routes/importers.ts +++ b/apps/api/src/routes/importers.ts @@ -717,8 +717,20 @@ importersRouter.post('/:id/upload-tariff-csv', async (req: Request, res: Respons // serving a stale cache entry for up to 30s. await invalidateOnChainAccount(importer.id); - // Refresh the importer_metrics materialized view - await refreshImporterMetricsView(); + // #1091: refresh cost scales with total importer/bond/event volume system-wide + // (the view aggregates across ALL importers), not just this one upload, so as + // that volume grows this REFRESH gets slower and is more likely to time out or + // error under load. Isolated in its own try/catch, matching the alert-evaluation + // pattern just above — the tariff upload and on-chain collateral update have + // already succeeded by this point, so a slow/failed metrics refresh must not + // turn into a 500 for an otherwise-successful request. The periodic refresh job + // (see refreshImporterMetricsView's doc comment) remains as a backstop if this + // on-demand refresh fails. + try { + await refreshImporterMetricsView(); + } catch (err) { + console.error('[importers] importer_metrics refresh failed:', err); + } res.json({ annualDutyTotal, diff --git a/docs/investigations/contract-events-gin-index-at-scale.md b/docs/investigations/contract-events-gin-index-at-scale.md new file mode 100644 index 0000000..6a39ea9 --- /dev/null +++ b/docs/investigations/contract-events-gin-index-at-scale.md @@ -0,0 +1,103 @@ +# Investigation: `contract_events` JSONB GIN index at scale + +Issue: #1093 + +## Summary + +The issue's premise is that `idx_contract_events_raw_gin` (GIN index on +`contract_events.raw`, originally `apps/api/migrations/005_contract_events_jsonb_gin.sql`, +issue #238) "is used by admin event and importer event queries," and asks +whether its scan performance and size hold up at 10x event volume. + +Two things turned up during this investigation that the issue itself +doesn't anticipate, both more fundamental than the scan-performance +question it asks: + +## 1. The index was never actually applied to any real database + +Same situation as #1090's `idx_importers_created_at` (see +`docs/investigations/importers-list-pagination-at-scale.md`): #238's +closing work added `idx_contract_events_raw_gin` only to +`apps/api/migrations/005_contract_events_jsonb_gin.sql`, a file nothing +executes. `npm run migrate` / `db:migrate` only runs +`src/migrations/000N_*.ts` via `src/migrations/runner.ts`. Grepped every +file in `src/migrations/` and `src/db.ts` for `raw_gin`/`USING GIN`/`GIN`: +zero hits anywhere. So every database this pipeline has ever actually +provisioned has never had this index — every query touching +`contract_events.raw`, if any existed, would be doing a sequential scan +across every partition today. + +## 2. Nothing in the codebase writes or reads `contract_events.raw` + +This is the more important finding. `raw JSONB` is declared on the table +(`0001_initial_schema.ts:46`, re-declared identically for the partitioned +table in `0002_partition_contract_events.ts:69,229`), but: + +- **Every `INSERT INTO contract_events` in application code omits `raw` + entirely** — checked all four insert call sites: + `apps/api/src/queue.ts:163-165`, `apps/api/src/routes/importers.ts:166-169` + and `:697-699`, `apps/api/src/routes/admin.ts:525-528`. None of their + column lists include `raw`, so every row any of these paths create has + `raw IS NULL`. (The only places `raw` appears at all are the + copy-through-partition-cutover `INSERT ... SELECT ... raw ...` statements + in `0002_partition_contract_events.ts:126-129,237-240`, which move + whatever's already in the column — always NULL — rather than populate + it.) +- **No `SELECT` anywhere filters, projects, or otherwise reads `raw`** — + checked every route file and service under `apps/api/src`. The two + endpoints the issue names as the JSONB-index consumers don't touch it: + `GET /admin/events` (`importers.ts:231-292`) selects + `id, importer_id, kind, amount, tx_hash, created_at, ledger_sequence, +event_index` and filters only on `created_at`; + `GET /:id/events` (`importers.ts:434-475`) selects + `id, kind, amount, tx_hash, created_at` and filters on `importer_id` and + a keyset `id` cursor. Neither uses a JSONB containment operator (`@>`, + `?`, `->`, `->>`) against `raw`, or against anything. + +In other words: `contract_events.raw` is a column that's declared but +functionally dead in the current codebase — always NULL, never read. A GIN +index on an always-NULL column indexes nothing useful; it would still pay +its full write cost (index maintenance on every insert into the busiest +table in the schema, plus its own storage and VACUUM overhead) for zero +query benefit, since there's no query for it to accelerate. + +## Recommendation + +**Do not add `idx_contract_events_raw_gin`.** Doing so — even though it's +literally what #238/the legacy migration file describe, and even though +re-adding it might look like "finishing" that prior work — would be a pure +regression: write overhead on `contract_events` (already flagged as the +largest, partitioned-for-scale table in the schema, per +`0002_partition_contract_events.ts`'s own header comment) with no +corresponding read speedup, because nothing queries the column it would +index. + +If a future feature actually starts writing structured event payloads into +`raw` and querying them with JSONB operators, add the GIN index at that +point, in the same change that introduces the read query — not +speculatively ahead of it. Until then, this issue's AC question ("does GIN +index scan performance hold up at 10x volume") doesn't apply: there is no +GIN-indexed scan happening, at any volume, because there's no index and no +query that would use one. + +## Reproducing / measuring + +No live Postgres instance was available in this environment to run the +JSONB containment query benchmarks, index-size, or vacuum/reindex-duration +measurements this issue's AC asks for. That's moot here regardless: those +measurements are about a query pattern (JSONB containment against `raw`) +that doesn't exist anywhere in this codebase to benchmark. + +## Acceptance criteria status + +- [ ] Benchmark JSONB containment query latency at current volume and + simulated 10x volume — not applicable; no code path runs a JSONB + containment query against `raw` +- [ ] Measure GIN index size and vacuum/reindex duration at scale — not + applicable; the index doesn't exist in any real database this + pipeline provisions, and shouldn't be added (see Recommendation) +- [ ] Compare planner choice between GIN index scan and sequential scan + across volumes — not applicable, same reason +- [x] Recommend index tuning or partitioning if latency degrades — the + recommendation is not to add the index at all; see above +- [x] Report findings in the issue — this document diff --git a/docs/investigations/importer-metrics-mv-refresh-cost.md b/docs/investigations/importer-metrics-mv-refresh-cost.md new file mode 100644 index 0000000..83432ee --- /dev/null +++ b/docs/investigations/importer-metrics-mv-refresh-cost.md @@ -0,0 +1,121 @@ +# Investigation: `importer_metrics` materialized view refresh cost at scale + +Issue: #1091 + +## Summary + +`/admin/importers/metrics` (`apps/api/src/routes/importers.ts:346-360`) and +`/importers/:id/metrics` (`importers.ts:367-387`) both read from the +`importer_metrics` materialized view (defined in +`apps/api/src/migrations/0001_initial_schema.ts:467-497`, re-created +identically post-partitioning in +`0002_partition_contract_events.ts:183-213`). This is a different view from +the similarly-named `importer_metrics_mv` singleton dashboard-stats view +(#251) — the two are refreshed by two different functions in `db.ts` and +this issue is about `importer_metrics` specifically, the per-importer one. + +## Is `REFRESH ... CONCURRENTLY` used? + +Yes. `refreshImporterMetricsView()` (`apps/api/src/db.ts:953-959`) runs +`REFRESH MATERIALIZED VIEW CONCURRENTLY importer_metrics`, and the view has +the required unique index for that +(`idx_importer_metrics_importer_id`, `0001_initial_schema.ts:496-497`). So +concurrent _readers_ are never blocked by a refresh — that AC question has +a definite, code-verified answer. + +## What CONCURRENTLY doesn't fix: refresh cost scales with total system volume + +The view definition (`0001_initial_schema.ts:467-491`) aggregates across +**every** importer, with a `LEFT JOIN contract_events ce ON ce.importer_id = +i.id` and a `SUM(...) FILTER` over that importer's full event history, for +every row. A `REFRESH` — concurrent or not — has to recompute the entire +view, i.e. do that full join/aggregate over all importers × all bonds × all +contract_events, every time it runs. + +`refreshImporterMetricsView()` is called from exactly one place: +`POST /importers/:id/upload-tariff-csv` +(`importers.ts`, previously line 721, now inside the try/catch at +~line 730), synchronously awaited on every single tariff-CSV upload from +any importer. That means: + +- The cost of one importer's upload scales with **total** bond/collateral + volume across the whole system, not their own data — exactly what this + issue's AC asks about ("as underlying bond/collateral row counts grow"). +- At 10x volume, this refresh takes proportionally longer, all of it spent + synchronously inside the HTTP request for an action (uploading a tariff + CSV) that has nothing to do with the other importers whose data is being + re-aggregated. +- Before this investigation's fix, that `await` had no `try`/`catch` + around it: a slow refresh that happened to time out or fail (lock wait, + connection pool exhaustion, whatever) would throw past the point where + the on-chain collateral update and `tariff_uploads` row had **already + succeeded**, turning a successful upload into a client-visible `500`. + Every other non-critical side effect in this same handler (friendbot + funding in `POST /`, `evaluateTariffAlerts` two lines above) is + deliberately wrapped for exactly this reason; the refresh call was the + one exception. + +## Fix applied + +`importers.ts`: wrapped `await refreshImporterMetricsView()` in its own +`try`/`catch`, matching the adjacent `evaluateTariffAlerts` pattern and its +documented rationale. This doesn't change the refresh's cost or timing (it +is still awaited synchronously, so the staleness-window guarantee in +`refreshImporterMetricsView`'s doc comment — refresh completes before the +response is sent — is preserved), it only stops a slow/failed refresh from +turning an otherwise-successful upload into a `500`. + +## What wasn't changed, and why + +Removing the `await` (fire-and-forget) or dropping the on-demand call +entirely in favor of only the documented periodic-refresh cadence would +better address the _latency_ half of this issue (an upload response no +longer waits on a system-wide aggregate), but both are staleness/product +tradeoffs already made deliberately, per the doc comment on +`refreshImporterMetricsView`: + +> Near-zero latency for tariff upload mutations since refresh is triggered +> immediately. Up to 5 minutes latency... for on-chain events if relying +> on periodic refresh. + +Changing that tradeoff isn't a "fix a bug" change, it's a product decision +about acceptable staleness — out of scope for a minimum fix here, and left +as the concrete recommendation below instead. + +## Recommendation + +If refresh latency becomes a measured problem at real 10x volume: +incremental/partial refresh isn't available for materialized views in +Postgres (a `REFRESH` always recomputes the full definition), so the +options are (a) make the on-demand call fire-and-forget instead of awaited, +accepting a small staleness window on the upload response itself, or (b) +drop the on-demand refresh and rely solely on the periodic job, accepting +the documented up-to-5-minute staleness for tariff-upload-triggered changes +too. Either requires deciding how stale `/importers/:id/metrics` is allowed +to be immediately after an upload — a product call, not a technical one. + +## Reproducing / measuring + +No live Postgres instance was available in this environment to benchmark +actual `REFRESH` duration at simulated 10x bond/collateral volume or to +observe lock behavior against concurrent readers directly. The +`CONCURRENTLY`-avoids-reader-blocking conclusion is verified by reading the +migration and `db.ts` (not by observing it); the refresh-cost-scales-with- +total-volume conclusion follows from the view's own `LEFT JOIN`/aggregate +definition, not from a captured timing number. + +## Acceptance criteria status + +- [ ] Benchmark refresh duration at current volume and at simulated 10x + volume — not possible without a live DB in this environment +- [ ] Measure lock contention against concurrent reads during refresh — + same limitation; `CONCURRENTLY` is confirmed in use by code + inspection, which by definition avoids exclusive-locking readers +- [x] Document whether CONCURRENTLY refresh is used and its impact — yes, + confirmed (`db.ts:953-959`); it protects concurrent _readers_, not + the refresh-triggering request's own latency or error handling +- [x] Recommend refresh cadence or incremental-refresh alternative if + needed — see Recommendation above; incremental refresh isn't + available for materialized views in Postgres, so the real choice is + fire-and-forget vs. periodic-only, both staleness tradeoffs +- [x] Report findings in the issue — this document diff --git a/docs/investigations/importers-list-pagination-at-scale.md b/docs/investigations/importers-list-pagination-at-scale.md new file mode 100644 index 0000000..63852ac --- /dev/null +++ b/docs/investigations/importers-list-pagination-at-scale.md @@ -0,0 +1,111 @@ +# Investigation: `GET /importers` pagination at scale + +Issue: #1090 + +## Summary + +`GET /importers` (`apps/api/src/routes/importers.ts:189-206`) has two branches: + +```sql +-- surety_admin +SELECT i.id, i.legal_name, i.bond_id, i.stellar_address, i.created_at, u.email + FROM importers i JOIN users u ON u.id = i.user_id + ORDER BY i.created_at DESC + +-- non-admin +SELECT i.id, i.legal_name, i.bond_id, i.stellar_address, i.created_at + FROM importers i WHERE i.user_id = $1 +``` + +Neither branch has a `LIMIT`/`OFFSET` or cursor. The non-admin branch is +already bounded independent of table size: `POST /importers` rejects a +second registration for the same `user_id` with `409` (`importers.ts:72-76`, +enforced at the DB level by `importers.user_id UUID NOT NULL UNIQUE`), so a +user has at most one importer row — that branch returns 0 or 1 rows +regardless of total importer count and needs no pagination. + +The surety_admin branch is the real concern: it returns every importer in +the system, unbounded, on every load of the admin dashboard +(`apps/web/app/surety/page.tsx`, via `listImporters()` in +`apps/web/lib/api.ts:113`). + +## Query plan + +`ORDER BY i.created_at DESC` with no `WHERE` clause needs either a full +sequential scan + sort, or an index that's already sorted on `created_at +DESC`. No such index existed anywhere `npm run migrate` actually applies — +see `apps/api/src/migrations/0006_importers_and_events_perf_indexes.ts` for +how that was confirmed (grepped every real migration file and `db.ts`; the +index was only ever added to a legacy, unexecuted `.sql` file from an +earlier PR closing #257). That migration adds +`idx_importers_created_at ON importers(created_at DESC)`, so the plan-type +question this issue's AC asks about (Seq Scan vs. Index Scan) is answered: +it was Seq Scan + Sort before, Index Scan after. + +## What an index alone doesn't fix + +An index makes producing the sorted row set cheap; it does nothing about +response size. At N importers this endpoint always serializes and returns +all N rows, joined against `users`. That's the part of this issue's AC this +investigation could **not** resolve with a safe, minimal change: + +- The only current caller (`apps/web/app/surety/page.tsx`) renders the + full list with no pagination UI, "load more", or virtualization, and + calls `listImporters()` with no query parameters. +- Adding pagination to the response shape used by that caller would either + (a) change the default response shape and require the frontend to also + change (a bigger change than one issue in a 4-issue batch justifies), or + (b) be an opt-in parameter nothing calls yet, which is speculative code + serving no current caller. +- A correct keyset cursor for `ORDER BY created_at DESC` needs a compound + cursor on `(created_at, id)`, not `id` alone — `importers.id` is a random + `uuid_generate_v4()` value with no relationship to insertion order, so a + cursor keyed on `id` alone (the shape `GET /:id/events` already uses + elsewhere in this file, `importers.ts:448-459`) would not actually + preserve `created_at DESC` ordering across pages. Getting this right is + more than a drive-by addition to a query whose only caller doesn't + paginate today. + +## Recommendation + +- **Covering index: done** (`0006_importers_and_events_perf_indexes.ts`). + Safe, additive, no behavior change for any caller. +- **Cursor-based pagination: recommended, not implemented here.** When the + admin importer list is expected to grow past a few thousand rows (or the + dashboard page needs to stop rendering everything at once for other UX + reasons), add `cursor`/`limit` query params using a compound + `(created_at, id)` keyset — e.g. + `WHERE (i.created_at, i.id) < ($cursorCreatedAt, $cursorId) ORDER BY i.created_at DESC, i.id DESC LIMIT $limit` + — and update `apps/web/lib/api.ts`'s `listImporters()` plus the surety + dashboard page together in the same change, since an API-only change here + wouldn't fix anything the current frontend actually does. + +## Reproducing / measuring + +No live Postgres instance was available in this environment (no Docker +daemon, no local `psql`) to run the `EXPLAIN (ANALYZE, BUFFERS)` captures +and payload-size measurements this issue's AC asks for at 1x/5x/10x volume. +The Seq Scan → Index Scan conclusion above is a code-level/schema-level +deduction (no matching index existed; one now does), not a measured result. +`apps/api/tests/load/get-importers.js` (k6, added for #265) exercises this +endpoint's request-handling concurrency but not row-count scaling — it +doesn't seed 5x/10x importer rows, so it doesn't answer this issue's +question either. Capturing real `EXPLAIN ANALYZE` output at populated +volume, as `docs/query-analysis.md` did for #257, is the natural follow-up +once a staging DB with representative data is available. + +## Acceptance criteria status + +- [ ] Benchmark GET /importers response time at 1x, 5x, and 10x current + importer row count — not possible without a live DB in this + environment +- [ ] Capture EXPLAIN ANALYZE output at each scale — same limitation +- [ ] Measure payload size and serialization time contribution — same + limitation; qualitatively, payload size is unbounded and grows + linearly with importer count regardless of index (see above) +- [x] Recommend whether cursor-based pagination or a covering index is + warranted — both: the covering index is safe and implemented now; + cursor pagination is recommended for whenever the admin list needs to + stop rendering everything at once, implemented together with the + frontend consumer rather than speculatively here +- [x] Report findings in the issue — this document diff --git a/docs/investigations/oracle-price-feed-write-contention.md b/docs/investigations/oracle-price-feed-write-contention.md new file mode 100644 index 0000000..620c07c --- /dev/null +++ b/docs/investigations/oracle-price-feed-write-contention.md @@ -0,0 +1,98 @@ +# Investigation: `oracle_price_feed` write contention at high update frequency + +Issue: #1092 + +## Summary + +`oracle_price_feed` (`apps/api/src/migrations/0001_initial_schema.ts:368-389`) +is written by exactly one code path, +`insertOracleFeedRow()` (`apps/api/src/services/oracle-event-listener.ts:158-192`), +and read by `verify-oracle-data` (`importers.ts:956-1047`) and the admin +event/CSV routes (`apps/api/src/routes/admin.ts:256-390`). + +## Why this table structurally does not have a write-contention problem + +**It's insert-only, and every insert targets a brand-new row.** There is no +`UPDATE` anywhere in the codebase against `oracle_price_feed` (checked: the +only write is the single `INSERT` in `insertOracleFeedRow`). Postgres +row-level locks are acquired per-row on `UPDATE`/`DELETE`/`SELECT ... FOR +UPDATE`; concurrent `INSERT`s of distinct new rows never contend for the +same row lock, regardless of how frequently they happen. + +**The primary key is a random UUID, not a monotonically increasing value.** +`id UUID PRIMARY KEY DEFAULT uuid_generate_v4()` spreads inserts across the +whole B-tree key space rather than concentrating them at the rightmost leaf +page the way a `SERIAL`/`BIGSERIAL` PK would. That's the opposite of a +contention risk — high-frequency inserts of monotonically increasing keys +are the classic case for "buffer lock on the rightmost index page" +contention; a random UUID PK avoids it structurally, at the cost of less +sequential physical layout (a tradeoff already made here, not one this +investigation is proposing to change). + +**Duplicate suppression is index-backed, not lock-based.** Every insert +does `ON CONFLICT (tx_hash, importer_address) DO NOTHING`, backed by +`idx_oracle_price_feed_tx_importer` — a unique btree index. Postgres +resolves `ON CONFLICT DO NOTHING` via the index's own insertion path (a +brief index-page-level operation, not a table-level or advisory lock), so +this doesn't introduce contention beyond what any unique-indexed insert +already has. + +**Concurrent readers are never blocked by concurrent writers.** Postgres +MVCC means plain `SELECT`s (which is all `verify-oracle-data` and the +admin routes do against this table — no `SELECT ... FOR UPDATE` anywhere) +read a consistent snapshot and never wait on in-flight `INSERT`s, at any +write frequency. + +## Where a real bottleneck could plausibly show up instead + +Not lock contention, but plain resource cost as insert _volume_ (not +_frequency_ of conflicting writes — there are none) grows: + +- `idx_oracle_price_feed_importer (importer_id, created_at DESC)`, + `idx_oracle_price_feed_ledger (ledger_sequence)`, and the unique + `(tx_hash, importer_address)` index are three B-tree indexes maintained + on every insert. That's a fixed per-row write-amplification cost + (3 index entries per row), not a contention issue — it scales linearly + with insert count, same as any indexed table. +- `admin.ts`'s CSV-export route (`apps/api/src/routes/admin.ts:338-390`) + streams the _entire_ `oracle_price_feed ${where}` result set. Like + #1090's `GET /importers`, an unbounded read against a fast-growing + insert-only audit table is a real future-scaling question — but it's a + read-size concern, not the write-contention this issue specifically + asks about, so it's out of scope here rather than folded in as a + drive-by fix. + +## Recommendation + +**No index or schema change is warranted based on this structural +analysis.** The table's design (insert-only, random UUID PK, index-backed +dedup, no row-level updates) already avoids the write-contention failure +modes that would show up under high-frequency concurrent price updates. If +production monitoring later shows real write-latency degradation despite +this, the next step would be capturing `pg_stat_activity`/`pg_locks` +during an actual burst — that requires live traffic or a realistic-load +staging environment, which this investigation didn't have available, and +which no amount of further code reading can substitute for. + +## Reproducing / measuring + +No live Postgres instance was available in this environment to run the +insert-throughput and concurrent-read-latency benchmarks this issue's AC +asks for. The conclusion above is a structural analysis of the schema and +every write/read call site (grepped exhaustively — `insertOracleFeedRow` is +the only write), not a measured result. + +## Acceptance criteria status + +- [ ] Benchmark insert throughput on oracle_price_feed at increasing update + frequencies — not possible without a live DB in this environment +- [ ] Measure read query latency for concurrent readers during heavy write + bursts — same limitation; structurally, MVCC means writers don't + block readers regardless of frequency (see above) +- [x] Identify lock type and duration for concurrent writers — none of + consequence: distinct-row inserts never share a row lock, and the + unique-index conflict check is index-level, not table-level +- [x] Recommend indexing or partitioning changes if contention is + significant — no changes recommended; structural analysis found no + contention mechanism for this table's access pattern +- [x] Report findings in the issue — this document