7392bb63 - Add the (tradingRuleId, id) index for the per-rule max-order aggregate - #4497
Conversation
… the per-rule max-order lookup
TradingRuleService.getCurrentTradingOrders ran a table-wide GROUP BY MAX(id)
aggregate over trading_order once per minute (LogJobService), scanning 5.4M
rows to compute 17 maxima (474 ms, ~680 MB read per call).
The obvious per-rule rewrite is not safe on its own: measured in production
against the current index set, a correlated MAX(id) subquery per rule takes
4,329 ms — 9x slower than today, because Postgres has no index to jump
straight to a rule's row range and PostgreSQL 17.10 has no index skip scan.
So this ships the composite index and the query rewrite together, never
apart: migration/1785480000000-AddTradingOrderRuleIdIndex.js adds
("tradingRuleId", "id"), and getCurrentTradingOrders now looks up the max
id per rule directly, letting Postgres use that index (17 cheap lookups
instead of one full scan). The existing single-column index on
tradingRuleId is left in place; the migration docstring notes the
resulting redundancy.
Added trading-rule.service.pg.spec.ts (pg-mem, real generated SQL) to pin
the rewrite: highest id per rule, rules without orders produce no entry,
an order with no matching rule is excluded, and the result is checked
against the pre-rewrite aggregate on the same seeded data.
…e-off AddTradingOrderRuleIdIndex1785480000000 sorted before the already-merged ClearDevUserSignatures1785500000000, so it would run before an already-executed migration. Renamed to 1785510000000 (class name and name property updated to match); the generated index name IDX_710fd49e19d248643cb2afa70f is unaffected, since TypeORM derives it from the table and column names, not the timestamp. Also documents in getCurrentTradingOrders that the per-rule MAX(id) lookups are an N+1 pattern that scales linearly with the rule count (currently 17 rules -> 19 queries), and why a single correlated-subquery alternative was skipped: it would give up testability against the lightweight mirror entities used in trading-rule.service.pg.spec.ts.
50f65fc to
340f56b
Compare
…alone The per-rule rewrite is withdrawn. It traded one READ COMMITTED snapshot for N, so two rules could report maxima from different points in time — not acceptable where LogJobService writes the FinanceLog from the result. The coherent single-statement alternative is a correlated subquery, which pg-mem cannot execute at all, so it could not be covered by a test that runs. The index stays: measured against a Postgres 17.10 rebuild with production planner settings, the unchanged aggregate moves from a Parallel Seq Scan over 93,486 blocks to a Parallel Index Only Scan over 15,020 with zero heap fetches. The docstring drops the claim that index and rewrite must ship together. The test loses its comparison against a re-created copy of the same aggregate, which could no longer fail, and gains an empty-rule-table case plus an explicit assertion that no null reaches the In(...) list.
❌ TypeScript: 1 errors |
The opening sentence claimed ~920 MB, which matches none of the measurements this migration cites — production reports 698 MB for the heap and the rebuild 730 MB. The intro now carries no figures at all; the measured ones stay further down, each with its source named.
The suite was written for a query shape that took no join, so the mirror entity carried only the tradingRuleId column. Restoring the single-statement aggregate brought back innerJoin on a relation path, which TypeORM resolves through entity metadata — all three tests aborted with 'Relation with property path tradingRule in entity was not found'. Foreign key creation stays off so the deliberately orphaned fixture remains insertable.
…e-up shape The cast asserted a hand-written shape onto findBy's argument type, which also allows an array — TypeScript rejected it as insufficiently overlapping. The argument is now narrowed: an array condition throws, then instanceof FindOperator establishes the real TypeORM type. The assertion itself is unchanged, and every unexpected shape fails loudly rather than skipping.
The comment claimed a different operator would fail loudly, but the guard tests the FindOperator base class — Any, Not and Between all pass it. The guard's real job is rejecting a bare value that would slip past the array check, and the operator choice is not what this test is about.
|
Four full review passes to reach zero findings, each with an independent conformance and logic lens. Recorded here after the merge for the trail. Pass 1 — one merge blocker: the migration docstring opened with a table size ("~920 MB") that matched none of the measurements it cited, and carried no source attribution while every other figure had one. Removed; the measured figures further down keep their sources named. Pass 2 — conformance clean. The logic lens, asked for a fresh look rather than a repeat, found what pass 1 had missed: the pg-mem mirror entity declared only the Pass 3 — both fixes clean, zero blockers. Both lenses independently flagged one comment that promised more than its guard delivers: it claimed "a different operator" would fail loudly, but the check is Pass 4 — zero blockers. The comment now states what the guard actually does. Two things are worth stating plainly rather than leaving in the log: The scope of this PR shrank during review. It originally paired the index with a rewrite of the query into per-rule lookups. That rewrite was withdrawn: it traded one READ COMMITTED snapshot for N, and the coherent single-statement alternative is a correlated subquery, which pg-mem cannot execute at all — so it could not be covered by a test that runs. The faster shape (52 blocks against 15,020) is recorded in the description so it can be picked up deliberately, with the test infrastructure change it needs. The planner's choice was verified before merge rather than predicted: on a PostgreSQL 17.10 rebuild with production planner settings and the real per-rule distribution, the unchanged aggregate moves from a Parallel Seq Scan over 93,486 blocks to a Parallel Index Only Scan over 15,020 with zero heap fetches. Confirming that on production itself, with |
Adds a
("tradingRuleId", "id")index for the per-rule max-order aggregate ontrading_order— the third sequential-scan source found after #4484 was deployed. Index only; the query is unchanged.The finding
After #4484 shipped, the sequential-scan load on
trading_orderdropped from 27.1 M to 10.8 M rows per 45 s. The remainder has a third source, located by high-frequencypg_stat_activitysampling (21 of 22 hits):TradingRuleService.getCurrentTradingOrders, called fromLogJobService— once a minute. Production EXPLAIN:5.4 million rows read to produce 17 maxima — there are exactly 17 trading rules. The table holds 5,421,148 rows in 698 MB.
What this PR does, and what it no longer does
An earlier revision of this branch also rewrote the query into per-rule lookups. That half is reverted. Two reasons, both from the review:
1. Snapshot coherence. The single-statement aggregate is one READ COMMITTED snapshot, so all 17 maxima come from the same instant. Per-rule statements are N snapshots; with ~4,350 inserts a day one rule can report a maximum from before a commit and another from after — a combination the aggregate can never produce.
LogJobServicewrites the FinanceLog from this, so that is not acceptable.2. The coherent single-statement alternative is untestable in this repository. The tests run against pg-mem 3.0.14, which does not support correlated subqueries at all. Probed directly against pg-mem, without TypeORM in between:
So the query stays as it is: coherent by construction, and covered by an executing test.
The index helps the unchanged query — measured, not predicted
The open question was whether the planner picks the index for
GROUP BYover 17 groups, or stays on the parallel sequential scan. Answered on a PostgreSQL 17.10 rebuild with production planner settings, 5,421,152 rows at the real per-rule distribution, 730 MB:Heap Fetches: 0DISTINCT ON, with indexThe planner does pick it: 84 % fewer block accesses, and
Heap Fetches: 0means a genuine index-only scan. The index is 116 MB.The size is the point, more than the wall clock. In production only 4.2 % of the 698 MB table sits in the 1 GB
shared_buffers, so it is re-read essentially in full every minute. A 116 MB index has a real chance of staying resident; the table demonstrably does not.The factor left on the table
The correlated form is dramatically faster — 52 blocks against 15,020 — because it does one backward index scan per rule with an early abort. It is not taken here because pg-mem cannot execute it, so it could not be covered by a test that actually runs.
DISTINCT ONwould be testable but is slower than doing nothing: the mixed sort direction (tradingRuleIdascending,iddescending) forces an incremental sort that spills to disk.Recorded so it can be picked up deliberately. Replacing pg-mem for that suite is its own piece of work, not part of a performance PR.
Verification
Index name derived independently three ways (Python,
shasum, and TypeORM's ownDefaultNamingStrategyfrom this repo'snode_modules) →IDX_710fd49e19d248643cb2afa70f.format:check,lintandtype-checkclean; affected suites green. The test keeps its concrete per-rule id expectations, and a mutation run backs them: changingMAXtoMINturns it red, reverting turns it green. Two gaps were closed — an emptytrading_ruletable, and an explicit assertion that the id list handed tofindBycontains nonull. The tautological comparison against a re-created copy of the same aggregate was removed: with the query unchanged it compared the aggregate to itself and could no longer fail.Caveat
All timings and plans above are externally measured — the 489 ms and the cache distribution against the production database, the plan and block counts against the rebuild. Neither is reproducible from this repository, and the migration docstring says so. The production plan should be confirmed with
EXPLAIN (ANALYZE, BUFFERS)after deployment; if the planner does not pick the index, dropping it changes nothing else, since no code depends on it.