02adf11f - Merge the two log indexes, close ledger master-switch test gaps, report non-finite prices, fix the AsyncCache invalidation race - #4534
Draft
TaprootFreak wants to merge 4 commits into
Conversation
IDX_b7eda1156aca7b2a1302cdf88f was added to make the Overview chart query index-only. It cannot: the query selects created, id, totalBalanceChf and btcPriceChf, and id is neither a key nor an INCLUDE column, so the planner falls back to IDX_log_financial_query. Production EXPLAIN (ANALYZE, BUFFERS) over the full 33136-row result: 163.2 ms with id in the select list, 12.8 ms without it. One column separates the two plans. The replacement keeps the key columns of IDX_log_financial_query, so all 16687 scans it serves today are unaffected, and carries the two payload columns as INCLUDE so the chart query becomes index-only-capable. One index instead of two, roughly 54 MB instead of 88 MB. CREATE first, both DROPs last: all pending migrations share one transaction and Postgres holds locks until COMMIT, so the ACCESS EXCLUSIVE taken by a DROP blocks reads on log for the rest of the run. Building first keeps that window as late and short as possible.
Reset Config.ledger.enabled in an afterEach in crypto-input-cutover, the one spec of four that set the flag without restoring it. Discover cron methods through MetadataScanner.getAllMethodNames, the scanner DfxCronService uses in production, instead of a single getOwnPropertyNames level. A cron on an inherited method is registered in production and was invisible here. Fail on any accounting provider carrying Nest-native @Cron metadata. Such a method is started by @nestjs/schedule directly, never reaches DfxCronService, and would be silently absent from discovery: scheduled in production, never shown to consult the master switch, and beyond its Process kill-switch too. Both checks share one scanProviderMethods helper so they cannot disagree about which methods are in scope.
…ls silently Moving the dashboard aggregation off the JSON round-trip means NaN, Infinity and undefined now reach the arithmetic directly; JSON.stringify used to collapse them to null on the way in. buildLatestBalance now logs such a value once per asset entry and leaves it strictly alone: booking a broken price as zero would hide the very defect that needs fixing upstream. null stays exempt. approxPriceChf is nullable, 144 of 430 production assets hold NULL, and total * null is 0 exactly as before. The comparison is strictly !== null so undefined does not share that exemption, since it produces NaN. The silence was worse than it looks: in the else branch NaN poisons the whole blockchain total, in the Scrypt branch it makes the position vanish because spotChf > 0 is false for NaN, and -Infinity wipes out the entire blockchain group including the healthy assets on it. Tests cover all three inputs and assert the uncorrected aggregate, so a later silent normalisation to zero breaks them.
A refresh started before invalidate() wrote its result back afterwards, with
a fresh timestamp, so the invalidation was undone for up to a full validity
period. A refresh now captures an instance-wide generation counter when it
starts and writes back only if the counter is unchanged. Callers depend on
invalidation taking effect at once: FiatService.updatePrice() writes a price
and then invalidates the repository cache, and fiat.controller.ts states that
assumption in so many words.
The counter is deliberately instance-wide, not per key. invalidate('a') also
discards an in-flight refresh for 'b', which is harmless: the caller still
receives its data, only the cache entry is missing and is re-fetched.
get() now hands the fetched data through instead of reading it back from the
cache, which would be a TypeError on undefined once a write-back is discarded.
In-flight promises move to their own map, so an entry without data/updated is
now unrepresentable. The old finally handler spread a possibly deleted entry
and could leave one behind.
bitcoin-fee.service.spec only passed because of that half-written entry: it
made cache.has(id) true, so fallbackToCache swallowed the error into
undefined. The test claims 'should throw' and never asserted it - its own
comments concede it was unsure what the behaviour was. It now asserts what
its name promises.
Collaborator
Author
Verification statusCI is green across all 12 checks on Run locally against the branch before pushing:
The production measurements behind point 1 were taken read-only ( Still open — do not read this as reviewed: the mandatory review pass has not run yet, so this stays a draft. Two things in it deserve a reviewer's attention specifically:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4531.
Works through all four follow-ups from the performance work merged on 2026-07-30 (#4519, #4520, #4521, #4527). Point 1 ends differently than the issue proposed — the measurement contradicted the premise.
1. The
logindex: not worthless, cut wrongThe issue asked whether
IDX_b7eda1156aca7b2a1302cdf88f(42 MB, 25idx_scan) earns its keep next toIDX_log_financial_query(46 MB, 16'687idx_scan), and proposed dropping it. Measuring first turned that around.The index was added by #4519 so the Overview chart query would be served by an Index Only Scan. It cannot do that. The query selects
created, id, "totalBalanceChf", "btcPriceChf", andidis neither a key nor an INCLUDE column of that index — so the planner falls back toIDX_log_financial_queryand the scan is not index-only. ProductionEXPLAIN (ANALYZE, BUFFERS), over the full 33'136-row result:idin the select listIDX_log_financial_queryidremovedIDX_b7eda…The 6.0 ms quoted in the issue holds only with a time window. The single production caller (
dashboard-financial.service.ts:34-42) passesto,limitandafterasundefinedthroughout, so the query carries noLIMIT— the 163 ms row is the one that runs.So a single column separates the two plans. Rather than drop the index and keep the slow plan, this replaces both indexes with one that does both jobs:
Its key columns are identical to
IDX_log_financial_query, so every access path that index serves today — all 16'687 scans, including the per-minuteLedgerMarkServicequery it was built for — is served unchanged, with the same ordering and selectivity. The two payload columns ride along as INCLUDE, which makes the chart query index-only-capable. One index instead of two, roughly 54 MB instead of 88 MB.On the evidence for that last claim, stated precisely: the combined plan itself was not measured —
hypopgis not available on the production database, so no hypothetical index could be planned. What is measured is both halves. ThedailySamplesubquery already gets anIndex Only Scan using IDX_log_financial_query(Heap Fetches 91 of 33'136), which shows the key columns includingidare index-only-servable and the visibility map is in good shape. Measurement D above shows the INCLUDE columns are too. An index holding both column sets covers all four selected columns; that is a conclusion from two measurements, not a third measurement.Migration ordering follows from the lock behaviour:
CREATE INDEXfirst, bothDROP INDEXlast. All pending migrations share one transaction (migrationsTransactionMode: 'all'), and Postgres holds locks until COMMIT — so the ACCESS EXCLUSIVE taken by each DROP blocks reads onlogfor the rest of the run. Building first keeps that window as late and short as possible. It also means this migration is best deployed on its own.CREATE INDEX CONCURRENTLYis not an option inside a transaction, same as for its two predecessors.The index name is the deterministic TypeORM
DefaultNamingStrategyname, not a chosen one (CONTRIBUTING.md:100,630, „no custom index names"). The formula was cross-checked by reproducing the existingIDX_b7eda1156aca7b2a1302cdf88ffrom it. As a side effectlogis left with only deterministically named indexes —IDX_log_financial_querywas itself a custom name.2. Ledger master switch: test gaps (#4521)
All three gaps from the issue, in
ledger-master-switch.spec.tsand its sibling:crypto-input-cutover.integration.spec.tsnow resetsConfig.ledger.enabledin anafterEach, matching the three sibling specs that already did (staleness-cutover.integration.spec.ts:331-333,ledger-booking-job.service.spec.ts:71-73,ledger-cutover.service.spec.ts:214-216). Not exploitable before — everybeforeEachbuilds a freshConfigService— but it was the one file out of four that differed.MetadataScanner.getAllMethodNames(), the very scannerDfxCronService.onModuleInituses in production, instead of a singleObject.getOwnPropertyNames()level. A cron on an inherited method is registered in production and was invisible to this test; the two views can no longer disagree. No discovered entry is lost —getAllMethodNamesis a superset that only dropsconstructorand accessors.@Cronmetadata. Such a method is started by@nestjs/scheduledirectly, so it never reachesDfxCronService— it would be silently absent from discovery: scheduled in production, never shown to consult the master switch, and out of reach of itsProcesskill-switch as well. Native@Cronis used legitimately elsewhere (transaction-request.service.ts:52,61), so the guard targets the ledger providers, not the import. Green today.Both checks share one
scanProviderMethods()helper so they can never disagree about which methods are in scope.SCHEDULE_CRON_OPTIONSis held as a string literal on purpose: the constant lives in@nestjs/schedule/dist/schedule.constantsand is not re-exported from the package root, so importing it would couple the test to the package's dist internals.3. Non-finite prices in the dashboard aggregation (#4520)
buildLatestBalancenow reports a non-finitepriceChfthroughlogger.error, once per asset entry, before the Scrypt/else split that multiplies it in either branch. The arithmetic is unchanged, character for character — no?? 0, no substitution, no skipped entry. Booking a broken price as zero is exactly the masking this codebase avoids, so the guard reports and leaves the value alone; the style followslog-job.service.ts:152-154, which does the same for a non-finite total.nullis deliberately exempt:approxPriceChfis nullable, 144 of 430 production assets hold NULL, andtotal * null === 0just as before the round-trip was removed. Logging those would drown the signal. The comparison is strictly!== nullso thatundefineddoes not share the exemption — it producesNaN.Worth recording, because it makes the silence worse than the issue assumed: the damage differs per path. In the
elsebranchNaNpoisons the whole blockchain total. In the Scrypt branch aNaNprice makes the position vanish instead, becausespotChf > 0is false forNaN. And-Infinitywipes out the entire blockchain group including the healthy assets on it, viarounded <= 0 → continue. All three were completely silent.Three new tests cover
NaN,Infinityandundefined; each asserts onelogger.errorand the uncorrected aggregate, so a later "fix" that quietly normalises to 0 breaks them. The existingnulltest gained an assertion that nothing is logged.Related finding, not fixed here: the same non-finite value also flows through
LogJobService.getBalancesByFinancialType(log-job.service.ts:277-287), whereUtil.roundReadabledoes not filter it either, so it reaches thebyTypebuckets. That path is already loud, though —log-job.service.ts:152-154,163-165logs and trips safety mode on a non-finite total. The blockchain aggregation was the only silent one, and that is what this closes. A fix at the root belongs ingetAssetLog/getBalancesByFinancialType.4.
AsyncCache.invalidate()outlived by an in-flight refreshA refresh now captures an instance-wide generation counter when it starts and writes its result back only if the counter is still unchanged.
invalidate()bumps it in both its forms, so any refresh already in flight loses its write-back instead of undoing the invalidation for up to a full validity period.The counter is deliberately instance-wide rather than per key.
invalidate('a')therefore also discards an in-flight refresh for'b'— conservative on purpose and harmless, because the caller still receives its data and only the cache entry is missing, to be re-fetched on next access.Two things had to change with it:
get()now hands the fetched data through instead of reading it back from the cache.return this.cache.get(id).datawould be aTypeErroronundefinedas soon as a write-back is discarded.data/updatedunrepresentable. The old.finallyhandler spread a possibly-deleted entry ({ ...this.cache.get(id), update: undefined }) and could leave exactly such a half-written entry behind.Dedup and
fallbackToCachesemantics are unchanged; public signatures,forceUpdateand the TTL behaviour are untouched. 19 new tests cover the race, the pass-through, dedup,fallbackToCachein both directions, TTL expiry and bothinvalidate()forms.One existing test had to be corrected, and it is worth saying why:
bitcoin-fee.service.spec.tspassed only because of the half-written entry. It madecache.has(id)true, sofallbackToCachefound something to fall back to and swallowed the error intoundefined. The test is named „should throw when fee estimation fails and no cache available" but never asserted that — its own comments conceded the uncertainty („it may return undefined or throw", „The actual behavior depends on AsyncCache implementation", „we just verify the estimateSmartFee was called"). It now asserts what its name promises. This was predicted from reading the code before the suite was run, and the run confirmed it.Verification
Run locally, all green:
npx eslintandnpx prettier --checkon every touched filemigration-psql-check.spec.ts, which scans migrations above a timestamp cutoff for MSSQL-only patternsProduction measurements for point 1 were taken read-only against the production database (
pg_stat_user_indexes,pg_stat_user_tables,EXPLAIN (ANALYZE, BUFFERS)). No DDL was run there.