Skip to content

perf(test-kit,api): cut integration-suite time - per-test truncation, shutdown sleep, cold jest cache, shared PrestaShop container (#1920) - #1923

Merged
piotrswierzy merged 6 commits into
mainfrom
1920-integration-suite-speedups
Jul 30, 2026
Merged

perf(test-kit,api): cut integration-suite time - per-test truncation, shutdown sleep, cold jest cache, shared PrestaShop container (#1920)#1923
piotrswierzy merged 6 commits into
mainfrom
1920-integration-suite-speedups

Conversation

@norbert-kulus-blockydevs

@norbert-kulus-blockydevs norbert-kulus-blockydevs commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Implements the three measured, self-contained items from #1920. Item #2 (pre-baked PrestaShop image) and item #4 (merging suite files) are deliberately not here - see Scope below.

1. Bounded in-flight drain instead of a flat 2 s sleep on shutdown

WebhookToJobHandler.stopConsumptionLoop slept setTimeout(2000) on every shutdown, "to let in-flight messages complete". Because ~77 int-specs tear the harness down, that was ~2 s x 77 paid per CI run, and it gave no real guarantee (a slow message was cut off at 2 s anyway).

The handler now tracks the processMessage call actually in progress and awaits that, bounded by the same 2 s. Nothing in flight (the common case, including every int-spec teardown) means shutdown returns immediately.

It deliberately does not await the consume loop itself: the loop parks in XREADGROUP ... BLOCK 5000, so awaiting it would stall shutdown for up to 5 s. BLOCK_MS is left alone, and quit() is kept - swapping it for disconnect() was measured on the same sample and buys nothing (see the issue's Verification log; that was a refuted hypothesis, not an oversight).

2. Truncate only the tables a test actually dirtied

truncateTables issued one TRUNCATE ... CASCADE per configured table on every afterEach, all 18 of them, whether the test touched them or not. Measured against the live test container:

Statement Time
SELECT 1 (round-trip baseline) ~0-1 ms
TRUNCATE 1 table ~9-10 ms
TRUNCATE the 18 configured tables ~207 ms
TRUNCATE all 46 tables in the schema ~390 ms

The cost is linear in the table count and flat in the row count - ~10 ms per table even when empty - while a typical test dirties two or three. At ~207 ms x 485 api tests that was ~100 s per run spent clearing empty tables.

Now: one probe round-trip (SELECT 't' WHERE EXISTS (SELECT 1 FROM "t") UNION ALL'd over the list) narrows the list, then a single TRUNCATE clears just those. A test that dirtied nothing issues no TRUNCATE at all. Measured: ~207 ms -> ~13 ms per reset.

Two things that look like the fix and are not (both measured, both rejected - don't re-litigate them without new numbers):

  • Batching alone (TRUNCATE a, b, c CASCADE for all 18): 280 ms -> 215 ms only. The cost is per-table, not per-round-trip.
  • Postgres durability flags (fsync=off, synchronous_commit=off, full_page_writes=off, wal_level=minimal): no gain at all, and passing them via withCommand made the 12-suite sample worse (30.3 s vs 19.2 s).

CASCADE was never the mechanism either: the schema has 4 foreign keys in total.

3. Persist the jest transform cache

Both integration configs now point cacheDirectory at .jest-cache/{api,worker}-integration (gitignored), and the test-integration job restores/saves it via actions/cache.

A cold ts-jest cache costs the first suite of each step ~32 s on CI, because every int-spec pulls the whole AppModule graph through ts-jest. Measured locally on the same file: 6.42 s warm vs 76.29 s cold. This is also what made order-reingestion-echo-guard look like a 37 s spec in the CI log - it is the run's first file, and its own it takes 149 ms. The worker step pays the same tax on its first suite (35.8 s against a 7.5 s floor).

ts-jest keys entries by file content, so restoring an older cache is safe: changed files miss and re-transform.

Measurements

12-suite sample, same machine, each change applied in isolation and reverted:

Configuration Time
baseline 41.5 s
+ item 1 (drain) 19.2 s
+ item 2 (dirty-only truncate) 10.1 / 12.7 / 14.6 s across runs

4. One shared PrestaShop container instead of four

Three PS specs now share a single container, booted on first use:
orders/allegro-prestashop-carrier-mapping, prestashop/prestashop-order-fulfillment-update,
orders/prestashop-harness-smoke.

prestashop/prestashop-webhook-provisioning deliberately keeps its own fresh container: its subject is the from-scratch install ("writes the three OPENLINKER_* configurations on first install"), and installing the OL module writes those same rows - sharing would erase what the spec asserts.

Phase breakdown of the boot this removes (instrumented locally, warm image cache):

Phase Time
startMysql 7.9 s
startPrestashop (container start incl. PS installing itself) 45.6 s
installOpenLinkerModuleIntoContainer (module + cache:clear + cache:warmup) 23.0 s
fixture seed, access URL, Apache probe 0.6 s
total 77.7 s

Three design points worth a reviewer's attention:

  • No PS-side cleanup runs between the sharing specs, and none is needed. Every assertion is either over an OL result array or filtered by the psOrderId the spec just created, so orders and carts left by an earlier file are inert. Verified by reading each assertion, not assumed.
  • The handoff is a FILE, not an env var. Jest hands every test file a fresh process object carrying a copy of process.env, so a value written by one int-spec is invisible to the next - measured, not assumed (same pid, second file reports no cached value). That is also why startContainers' CONTAINERS_PRIMED_ENV_VAR works for Postgres/Redis: globalSetup sets it in the parent before the worker gets its copy, a route a lazily-booted container cannot use. The record is liveness-checked with docker inspect before reuse, so a record left by a crashed run boots fresh instead of failing every spec with a connection error.
  • Teardown stops it from ids on disk. cleanup is a closure the globalTeardown realm cannot see, so the worker records the container ids and teardown stops them. Leaving them to the CI orphan sweep ([BUG] Infrastructure - Integration test Testcontainers leak on self-hosted CI runner exhausts docker0 bridge FDB, breaking pipelines #1285) would be a backstop, not a plan.

The shared container is always module-installed (the module-installed carrier satisfies the no-module spec's assertions; the reverse does not hold). The OL_SKIP_PS_MODULE_INSTALL=true escape hatch still works - in that mode a spec takes its own module-less container.

Measured locally: the three sharing specs went ~380 s -> 132.3 s, and the full api integration suite 622.6 s -> 431.2 s (85 suites, 501 tests, zero failures).

On CI the per-suite effect is unambiguous - two boots gone, -139 s across the four:

PS spec Before After
allegro-prestashop-carrier-mapping (boots the shared one) 108.9 s 106.3 s
prestashop-order-fulfillment-update 99.6 s 5.7 s
prestashop-harness-smoke 47.9 s < 5 s
prestashop-webhook-provisioning (own container, unchanged) 51.5 s 53.0 s
total 308 s ~169 s

Confirmed on an idle runner (re-run of the same commit, 0/4 runners busy, empty queue):

Step Baseline This PR Delta
Run integration tests (apps/api) 883.8 s 394 s -490 s (-55%)
Run worker integration tests 180.9 s 136 s -45 s (-25%)
whole Integration Tests job ~18m 44s 10m 01s -8m 43s (-47%)

The first run of this commit read 578 s for the api step and was not comparable: the runner was saturated (4/4 busy, 5 runs queued). All 17 non-PS suites present in both runs were slower that time, by +2.7 s each on average, including worker specs this PR cannot touch (job-intake-execution 16.7 -> 41.3 s) - not one was faster, and the worker step moved 137 -> 180 s on identical code. Re-measured once the queue drained.

Measured on CI

Run 30455348427, same self-hosted runner class, against the baseline of run 30443862548:

Step Baseline This PR (warm cache) Delta
Run integration tests (apps/api) 883.8 s 517 s -367 s (-42%)
Run worker integration tests 180.9 s 137 s -44 s (-24%)
whole Integration Tests job ~18m 44s 12m 07s -6.5 min

Same coverage on both sides: 81 suites / 485 passed / 7 skipped.

Supporting detail: in the baseline 81 of 81 suites reported a duration (jest prints one only above ~5 s); with this PR only 21 do. Per-suite deltas on the comparable ones run -1.7 s to -4.4 s, matching the local A/B.

Read the first run of this PR with care. Its api step showed only 797 s, because the actions/cache step was necessarily a MISS on the first run (nothing to restore yet) — and the run's first suite happened to be allegro-prestashop-carrier-mapping, the heaviest import graph in the repo, so it absorbed the whole cold-transform tax and read as 224 s instead of ~108 s. On the re-run, with the cache restored (Cache jest transform output = 3 s), that same suite is back to 108.9 s. Nothing regressed; the tax simply moved onto a fatter file before item 3 could do its job.

What is left in the 517 s is now dominated by the four PrestaShop suites (108.9 + 99.6 + 51.5 + 47.9 = 308 s, 60% of the step), which is exactly what issue item #2 (the pre-baked image) targets.

Scope: what is NOT in this PR

  • The pre-baked PrestaShop image. Container sharing (section 4) is in; baking an image with PS pre-installed is not. That would remove the remaining boot entirely (~45 s of install per boot), but it means building, tagging and cache-keying an image against the module's PHP source - infrastructure work with its own review surface, and it composes cleanly on top of the sharing done here.
  • Merging suite files. With the shutdown sleep gone, 77 non-PS suites cost ~209 s on CI, ~2.7 s each, nearly all of it the per-file app boot. Merging them to ~20 files is worth ~140 s but rewrites the layout of the whole integration suite; it wants its own change.
  • Anything touching CI topology (sharding, splitting the worker step into its own job). Explicitly ruled out by the maintainer; the actions/cache step here is a cache, not a topology change.

Verification

  • pnpm lint (incl. check:invariants) - clean
  • type-check for @openlinker/test-kit + @openlinker/api - clean
  • pnpm --filter @openlinker/api test - 78 suites / 1058 tests (5 new: shutdown returns immediately with nothing in flight, waits for an in-flight message, gives up after the bound, survives a failing in-flight message, still quits)
  • pnpm --filter @openlinker/test-kit test - 7 tests (4 new around the probe/skip semantics)
  • Webhook int-specs pass, including webhook-ingestion.int-spec.ts whose [TASK] Testing — webhook integration tests should drain the enqueued job through the worker handler #1511 case requires the running consumer to actually drain a published event and enqueue the job - the test that would catch a broken shutdown
  • Full local api integration suite green: 85 suites / 501 tests / 431.2 s, zero failures (the same suite was 622.6 s before section 4)

Docs

  • docs/testing-guide.md - the Testcontainers lifecycle section now describes the reset semantics correctly (only non-empty tables truncated, and why) and corrects a standing inaccuracy: it claimed migrations run before the integration suite. They do not - the schema comes from TypeORM synchronize, so the suite never proves the migrations reproduce the entity schema. That gap is called out, not fixed here.
  • docs/lessons.md - regression-ledger entry: profile harness phases before optimising a "slow suite"; four plausible hypotheses were wrong on this one.

Closes #1920

@piotrswierzy

Copy link
Copy Markdown
Collaborator

Tech Lead review — cut per-test truncation, blind shutdown sleep and cold jest cache

Verdict: 🔄 Approve with changes (comment — GitHub blocks self-approval). Genuinely good performance work with measurements behind every claim. One behavioural change in truncateTables deserves a second look before merge.


IMPORTANT — skipping empty tables also skips their CASCADE side effects

The old loop truncated every listed table individually with CASCADE. TRUNCATE X CASCADE clears X and every table with an FK referencing X — including tables that were never in the caller's list. The new code skips any table the probe finds empty, so those cascade side effects disappear whenever the parent happens to be empty.

That matters where a child row can outlive its parent, which is possible with a nullable FK: parent empty, child rows present, child not in the truncate list. Previously the child was wiped as collateral; now it survives into the next test.

The symptom would be an order-dependent int-spec failure that reproduces only in full-suite order and passes in isolation — precisely the class this repo has already been bitten by (reference_apps_web_flaky_full_suite, the #1884 order-dependent test). Since the whole point of this PR is to make the suite faster and more trustworthy, it'd be a shame to trade wall-clock for a new flake class.

Two ways to settle it cheaply:

  • Verify it's vacuous — if every table reachable by CASCADE from a listed table is also in the list, the behaviour is provably identical and a one-line comment saying so closes the question permanently.
  • Or make it explicit — probe with the cascade closure rather than the literal list (pg_constraint-derived), or simply keep CASCADE semantics by not short-circuiting parents that have dependents.

I'd not block on this if you can show the closure property holds; I'd want it stated either way, because the current comment justifies the optimisation purely on row counts and never mentions cascade.

The measurements are what make this reviewable

Batching alone was measured and is NOT the win (18 statements 280 ms vs one combined 215 ms); skipping empty tables is.

Recording the optimisation you tried and rejected, with numbers, is worth as much as the one you kept — it stops the next person re-attempting batching and concluding the code is naive. Same for ~10 ms per table regardless of contents, linear in table count, flat in row count: that's the actual cost model, and it's what makes "one probe round-trip then one TRUNCATE" obviously right rather than merely plausible.

The shutdown fix is strictly better than what it replaces

Replacing await new Promise(r => setTimeout(r, 2000)) with awaiting the tracked inFlightMessage is correct on both axes the old code got wrong:

  • Faster in the common case — nothing in flight costs nothing, versus 2 s × 77 int-spec teardowns.
  • No weaker a guarantee — the flat sleep also cut a slow message off at 2 s, so the bound didn't change; it just stopped being paid unconditionally.

The details are right too: not awaiting the consume loop (it can be parked in XREADGROUP ... BLOCK 5000, so awaiting it would make shutdown slower than the sleep it replaced), inFlight.catch(() => undefined) so a mid-shutdown failure can't turn app.close() into a rejection, clearTimeout in finally so the bound promise doesn't hold the event loop, and inFlightMessage = null in a finally so a throwing message doesn't strand the field.

The ts-jest cache is correctly reasoned

restore-keys with a github.sha primary key gives a fresh entry per run plus a fallback to the most recent — right shape for a cache that's cheap to partially miss. And the safety argument is the one that matters: ts-jest keys entries by file content, so restoring an older cache can't serve stale output; changed files simply miss. .jest-cache/ added to .gitignore and both integration configs pointed at it — no half-wiring.

SUGGESTION — the probe interpolates table names into SQL

`SELECT '${table}' AS table_name WHERE EXISTS (SELECT 1 FROM "${table}")`

Same trust level as the code it replaces (which already did TRUNCATE TABLE "${table}"), and the values are hard-coded constants in test helpers, so this isn't an injection finding. But the function is exported from @openlinker/test-kit — a published surface plugin authors consume — so a caller could pass an arbitrary string. A cheap /^[a-z_][a-z0-9_]*$/i assert with a clear throw would keep it honest without changing the hot path. Optional.

Priority

  1. Confirm (or handle) the CASCADE-closure question in truncateTables.

Everything else is ready to go.

@norbert-kulus-blockydevs
norbert-kulus-blockydevs force-pushed the 1920-integration-suite-speedups branch from e0088f0 to ac7da9b Compare July 29, 2026 22:08
norbert-kulus-blockydevs added a commit that referenced this pull request Jul 29, 2026
…e PS specs

Four int-specs each booted their own PrestaShop + MySQL trio. A boot is 77.7 s
locally - 45.6 s of it PrestaShop installing itself into MySQL, 23.0 s the OL
module install plus Symfony cache warmup - and on CI those four specs were 308 s,
60% of the api integration step after #1923 landed the cheaper wins (#1920).

Three of them now share one container, booted on first use:

- `orders/allegro-prestashop-carrier-mapping`
- `prestashop/prestashop-order-fulfillment-update`
- `orders/prestashop-harness-smoke`

`prestashop/prestashop-webhook-provisioning` deliberately keeps its own fresh
container: its subject IS the from-scratch install ("writes the three
OPENLINKER_* configurations on FIRST install"), and installing the OL module
writes those same rows, so sharing would erase what the spec asserts.

No PrestaShop-side cleanup runs between the sharing specs, and none is needed:
every assertion is either over an OL result array or filtered by the
`psOrderId` the spec just created, so orders and carts left behind by an
earlier file are inert. Verified by reading each assertion rather than assuming.

The shared container is always module-installed. The module-installed carrier
satisfies the no-module spec's assertions (`external_module_name = 'openlinker'`,
matching `olDynamicCarrierId`) while the reverse does not hold. The
`OL_SKIP_PS_MODULE_INSTALL=true` escape hatch still works - in that mode a spec
takes its own module-less container instead of the shared one.

The handoff between specs is a FILE, not an env var. Jest hands every test file
a fresh `process` object carrying a COPY of `process.env`, so a value written by
one int-spec is invisible to the next - measured, not assumed (same pid, second
file reports no cached value). That is also why `startContainers`'
`CONTAINERS_PRIMED_ENV_VAR` works for Postgres/Redis: `globalSetup` sets it in
the parent before the worker receives its copy, a route a lazily-booted
container cannot use. The record is liveness-checked with `docker inspect`
before reuse, so a record left behind by a crashed run boots a fresh container
instead of failing every spec with a connection error.

Because `cleanup` is a closure the `globalTeardown` realm cannot see, the worker
records the container ids on disk and teardown stops them from there. Leaving
them for the CI orphan sweep (#1285) would be a backstop, not a plan.

Measured locally: the three sharing specs went ~380 s -> 132.3 s, and the full
api integration suite 622.6 s -> 431.2 s (85 suites, 501 tests, zero failures).
On CI, where a boot is ~40 s rather than ~90 s, this is worth ~80 s: two boots
removed from the step.

Refs #1920
@norbert-kulus-blockydevs norbert-kulus-blockydevs changed the title perf(test-kit,api): cut per-test truncation, blind shutdown sleep and cold jest cache (#1920) perf(test-kit,api): cut integration-suite time - per-test truncation, shutdown sleep, cold jest cache, shared PrestaShop container (#1920) Jul 29, 2026
norbert-kulus-blockydevs added a commit that referenced this pull request Jul 30, 2026
…1923 review)

Two review findings from #1923.

IMPORTANT - skipping an empty table also skipped its CASCADE side effects.
The pre-#1920 reset ran one `TRUNCATE <t> CASCADE` per listed table, which
also clears every table holding an FK to `<t>`, including tables the caller
never listed. Probing only the literal list dropped that collateral clear
whenever the parent was empty while a dependent still held rows (possible
with a nullable FK) - the shape that produces order-dependent int-spec
flakes.

The probe now runs over the list's transitive CASCADE closure, walked from
`pg_constraint` and memoised per DataSource (the FK graph is fixed once
`synchronize` builds the schema), so the steady-state reset keeps its two
round-trips. The caller's own names are always probed even if Postgres
reports no such relation, so a typo still fails loudly.

The closure is currently vacuous for the apps/api list - verified against
the live test schema, not assumed: the resolver reports zero extra tables,
because the synchronize-built schema has exactly four FKs
(product_variants -> products, inventory_items -> products,
inventory_items -> product_variants, attribute_value_mappings ->
attribute_mappings) and every dependent of a listed table is itself listed.
Resolving it at runtime means the next FK does not have to be noticed by
hand.

SUGGESTION - `truncateTables` interpolates table names into SQL. Every
in-tree caller passes constants, but the helper is a published seam, so
names are now asserted against /^[a-z_][a-z0-9_]*$/i with a clear throw
before any statement is issued.

Verification: test-kit unit specs 10/10 (3 new - closure probing, one walk
per DataSource, identifier rejection); lint + type-check clean;
`connection-crud` + `inventory-multivariant-cleanup` int-specs green
against real Postgres (5.1 s), which is what exercises the new SQL.

Refs #1920

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

Review addressed - both findings, in ac382e92

IMPORTANT - CASCADE closure in truncateTables: handled and verified vacuous

You asked for either proof that the closure property holds or an explicit handling. Both are here, because the proof is true today but not self-maintaining.

Verified vacuous today. I resolved the closure against the live test schema (instrumented resolveCascadeClosure on a real int-spec run, then removed the probe) - it reports zero extra tables for the apps/api list. The reason: the synchronize-built test schema has exactly four FKs, and every dependent of a listed table is itself listed.

FK (from ORM relation decorators - migrations do not run in this path) Both ends in tablesToTruncate?
product_variants.productId -> products yes / yes
inventory_items.productId -> products yes / yes
inventory_items.productVariantId -> product_variants (nullable) yes / yes
attribute_value_mappings.attribute_mapping_id -> attribute_mappings neither is listed - nothing to cascade from

The nullable FK you correctly singled out as the divergence case (inventory_items.productVariantId) has both ends listed, so parent-empty-child-dirty cannot strand rows.

Made explicit anyway. A hand-verified property that a future @ManyToOne silently invalidates is exactly the flake class you flagged, so the probe now runs over the transitive CASCADE closure of the caller's list, walked from pg_constraint:

WITH RECURSIVE requested AS (
  SELECT c.oid FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE c.relname IN (...) AND n.nspname = ANY (current_schemas(false))
), closure AS (
  SELECT oid FROM requested
  UNION
  SELECT con.conrelid FROM pg_constraint con
  JOIN closure ON con.confrelid = closure.oid WHERE con.contype = 'f'
) SELECT c.relname AS table_name FROM closure JOIN pg_class c ON c.oid = closure.oid

Resulting state is identical to the old per-table TRUNCATE ... CASCADE loop's, by construction: anything the old loop cleared as collateral is now probed, and an empty table needs no clearing.

Two details worth naming:

  • No added round-trip in steady state. The FK graph is fixed once synchronize builds the schema, so the walk is memoised per DataSource (WeakMap, keyed by the table list). One extra query per int-spec file, not per afterEach; the reset stays at probe + TRUNCATE.
  • The caller's own names are always probed, even when Postgres reports no such relation. A typo'd table must keep failing loudly at probe time rather than being silently dropped from the closure.

The truncateTables doc comment now states the cascade reasoning (it previously justified the optimisation on row counts alone, as you noted), and docs/testing-guide.md says the same in the lifecycle section.

SUGGESTION - interpolated table names: taken

truncateTables now asserts every name against /^[a-z_][a-z0-9_]*$/i and throws test-kit: refusing to truncate "<name>" - ... before issuing any statement. Discovered dependents from the closure walk go through the same assert. Not a hot-path cost (one regex per table, once per reset) and it keeps the published seam honest.

Verification

  • test-kit unit specs 10/10, 3 new: closure probing keeps a dependent that only CASCADE would have cleared; the pg_constraint walk runs once per DataSource; a non-identifier table name is rejected with zero queries issued
  • eslint + tsc --noEmit for @openlinker/test-kit clean
  • connection-crud + inventory-multivariant-cleanup int-specs green against real Postgres (2 suites / 10 tests / 5.1 s) - that is what exercises the new SQL rather than a fake

Note on the numbers

The measurements in the description say "18 configured tables"; the list is 19 since mcp_tokens arrived with #1486. The cost model (~10 ms per table, linear in count) is unchanged, so the conclusion stands - flagging it so the figure is not read as exact.

… cold jest cache

Three measured, independent costs in the integration suite, none of which is
test logic. Numbers below are from instrumented probes and A/B runs on one
machine with a revert between variants; see #1920 for the full log, including
four hypotheses that measurement refuted.

1. Shutdown slept a flat 2 s, every time
   `WebhookToJobHandler.stopConsumptionLoop` ended with `setTimeout(2000)` "to
   let in-flight messages complete". ~77 int-specs tear the harness down, so
   that was ~2 s x 77 per run - and it guaranteed nothing, since a slow message
   was cut off at 2 s regardless. The handler now tracks the `processMessage`
   call actually in progress and awaits that, bounded by the same 2 s: with
   nothing in flight (every int-spec teardown) shutdown returns immediately.
   It deliberately does not await the consume loop, which parks in
   `XREADGROUP ... BLOCK 5000`. A failing in-flight message is swallowed so
   `app.close()` cannot start rejecting where the old sleep never could.
   `BLOCK_MS` and `quit()` are untouched - swapping `quit()` for `disconnect()`
   was measured and buys nothing.

2. Every test truncated all 18 tables, empty or not
   `TRUNCATE` costs ~10 ms per table regardless of contents (1 table ~10 ms,
   18 ~207 ms, all 46 ~390 ms - linear in table count, flat in row count) while
   a typical test dirties two or three. At ~207 ms x 485 api tests that was
   ~100 s per run spent clearing empty tables. `truncateTables` now asks in one
   round-trip which tables hold a row and truncates only those: ~207 ms ->
   ~13 ms, and a test that dirtied nothing issues no TRUNCATE at all.
   Batching alone is NOT the win (280 ms -> 215 ms), Postgres durability flags
   gave nothing, and CASCADE was never the mechanism (the schema has 4 FKs).

3. The transform cache was thrown away between runs
   Neither integration config set `cacheDirectory`, so each run re-transformed
   the whole AppModule graph into a throwaway /tmp dir. That tax lands on the
   run's FIRST suite: the same file takes 6.42 s warm and 76.29 s cold locally,
   which is why `order-reingestion-echo-guard` reads as a 37 s suite on CI while
   its own `it` takes 149 ms. Both configs now cache into `.jest-cache/` and the
   job restores it via `actions/cache`. ts-jest keys entries by content, so a
   restored older cache is safe - changed files miss and re-transform.

Measured on a fixed 12-suite sample: 41.5 s baseline -> 19.2 s with (1) ->
10.1-14.6 s with (1)+(2). Full local api integration suite green: 83 suites,
485 tests, 622.6 s.

Docs: `docs/testing-guide.md`'s lifecycle section now describes the reset
semantics and corrects a standing inaccuracy - it claimed migrations run before
the integration suite. They do not; the schema comes from TypeORM `synchronize`,
so the suite never proves the migrations reproduce the entity schema. That gap
is flagged, not fixed here. `docs/lessons.md` gains the profiling lesson.

Refs #1920

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
…e PS specs

Four int-specs each booted their own PrestaShop + MySQL trio. A boot is 77.7 s
locally - 45.6 s of it PrestaShop installing itself into MySQL, 23.0 s the OL
module install plus Symfony cache warmup - and on CI those four specs were 308 s,
60% of the api integration step after #1923 landed the cheaper wins (#1920).

Three of them now share one container, booted on first use:

- `orders/allegro-prestashop-carrier-mapping`
- `prestashop/prestashop-order-fulfillment-update`
- `orders/prestashop-harness-smoke`

`prestashop/prestashop-webhook-provisioning` deliberately keeps its own fresh
container: its subject IS the from-scratch install ("writes the three
OPENLINKER_* configurations on FIRST install"), and installing the OL module
writes those same rows, so sharing would erase what the spec asserts.

No PrestaShop-side cleanup runs between the sharing specs, and none is needed:
every assertion is either over an OL result array or filtered by the
`psOrderId` the spec just created, so orders and carts left behind by an
earlier file are inert. Verified by reading each assertion rather than assuming.

The shared container is always module-installed. The module-installed carrier
satisfies the no-module spec's assertions (`external_module_name = 'openlinker'`,
matching `olDynamicCarrierId`) while the reverse does not hold. The
`OL_SKIP_PS_MODULE_INSTALL=true` escape hatch still works - in that mode a spec
takes its own module-less container instead of the shared one.

The handoff between specs is a FILE, not an env var. Jest hands every test file
a fresh `process` object carrying a COPY of `process.env`, so a value written by
one int-spec is invisible to the next - measured, not assumed (same pid, second
file reports no cached value). That is also why `startContainers`'
`CONTAINERS_PRIMED_ENV_VAR` works for Postgres/Redis: `globalSetup` sets it in
the parent before the worker receives its copy, a route a lazily-booted
container cannot use. The record is liveness-checked with `docker inspect`
before reuse, so a record left behind by a crashed run boots a fresh container
instead of failing every spec with a connection error.

Because `cleanup` is a closure the `globalTeardown` realm cannot see, the worker
records the container ids on disk and teardown stops them from there. Leaving
them for the CI orphan sweep (#1285) would be a backstop, not a plan.

Measured locally: the three sharing specs went ~380 s -> 132.3 s, and the full
api integration suite 622.6 s -> 431.2 s (85 suites, 501 tests, zero failures).
On CI, where a boot is ~40 s rather than ~90 s, this is worth ~80 s: two boots
removed from the step.

Refs #1920
…1923 review)

Two review findings from #1923.

IMPORTANT - skipping an empty table also skipped its CASCADE side effects.
The pre-#1920 reset ran one `TRUNCATE <t> CASCADE` per listed table, which
also clears every table holding an FK to `<t>`, including tables the caller
never listed. Probing only the literal list dropped that collateral clear
whenever the parent was empty while a dependent still held rows (possible
with a nullable FK) - the shape that produces order-dependent int-spec
flakes.

The probe now runs over the list's transitive CASCADE closure, walked from
`pg_constraint` and memoised per DataSource (the FK graph is fixed once
`synchronize` builds the schema), so the steady-state reset keeps its two
round-trips. The caller's own names are always probed even if Postgres
reports no such relation, so a typo still fails loudly.

The closure is currently vacuous for the apps/api list - verified against
the live test schema, not assumed: the resolver reports zero extra tables,
because the synchronize-built schema has exactly four FKs
(product_variants -> products, inventory_items -> products,
inventory_items -> product_variants, attribute_value_mappings ->
attribute_mappings) and every dependent of a listed table is itself listed.
Resolving it at runtime means the next FK does not have to be noticed by
hand.

SUGGESTION - `truncateTables` interpolates table names into SQL. Every
in-tree caller passes constants, but the helper is a published seam, so
names are now asserted against /^[a-z_][a-z0-9_]*$/i with a clear throw
before any statement is issued.

Verification: test-kit unit specs 10/10 (3 new - closure probing, one walk
per DataSource, identifier rejection); lint + type-check clean;
`connection-crud` + `inventory-multivariant-cleanup` int-specs green
against real Postgres (5.1 s), which is what exercises the new SQL.

Refs #1920

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
@norbert-kulus-blockydevs
norbert-kulus-blockydevs force-pushed the 1920-integration-suite-speedups branch from ac382e9 to 3fdd787 Compare July 30, 2026 07:01
@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

Second review pass — independent, on the rebased head

A fresh reviewer went over the whole diff (main...HEAD) without taking the description, the earlier review, or the "already addressed" claim at face value. Verdict: approve with changes — nothing blocking. Two IMPORTANT items, five SUGGESTIONs. Nothing here is fixed yet; posting first so the findings are reviewable separately from the changes.


IMPORTANT 1 — the closure walk's recursive term has no schema guard, and a cross-schema dependent breaks every reset

libs/test-kit/src/harness.ts:115. The requested CTE is filtered by current_schemas(false); the recursive term is not. A table in another schema holding an FK to a listed table therefore enters the closure and is then probed with a bare, unqualified name.

Reproduced against a scratch postgres:16-alpine: schema other holds xref(p int NULL REFERENCES public.parent(id)), caller list contains parent → the probe emits SELECT 'xref' … FROM "xref"ERROR: relation "xref" does not exist. Because the probe is one UNION ALL statement, that error fails every afterEach in the suite — strictly louder than the pre-change per-table loop, which never touched xref at all.

Not reachable in-tree (the api schema is all public), but tablesToTruncate is the plugin-author seam — the same audience the identifier assert was added for.

      '), closure AS (' +
        ' SELECT oid FROM requested' +
        ' UNION' +
        ' SELECT con.conrelid FROM pg_constraint con' +
        ' JOIN closure ON con.confrelid = closure.oid' +
        ' JOIN pg_class dep ON dep.oid = con.conrelid' +
        ' JOIN pg_namespace depns ON depns.oid = dep.relnamespace' +
        " WHERE con.contype = 'f'" +
        ' AND depns.nspname = ANY (current_schemas(false))' +
      ') SELECT c.relname AS table_name FROM closure JOIN pg_class c ON c.oid = closure.oid',

(If cross-schema dependents should be cleared instead of ignored: return nspname too and emit "schema"."table", with the identifier assert applied to both parts.)

IMPORTANT 2 — the PrestaShop section of docs/testing-guide.md still documents the per-spec boot this PR removes

The description's "## Docs" claim covers only the Testcontainers-lifecycle bullet. Four places now contradict the code (line numbers on this head, all re-checked):

  • :269 — the "How to opt in" sample tells a new spec author to call startPrestashopContainer() + afterAll(cleanup): the ~78 s-per-file path this PR exists to remove.
  • :282 — "The harness is suite-scoped — one boot per int-spec file, NOT global." False for 3 of the 4 PS specs now.
  • :317 — "Today only allegro-prestashop-carrier-mapping.int-spec.ts opts in; prestashop-harness-smoke.int-spec.ts … do not." The smoke spec now gets a module-installed container unconditionally.
  • :393 — "copy the structure: one suite-scoped PS container in beforeAll".

Failure scenario: a contributor adds a fifth PS spec by following :269/:393, boots a fourth container, re-adds ~78 s, and gets a container whose cleanup closure is the only thing that can stop it (SIGKILL leaks it onto the persistent runner). Suggested replacement for the opt-in sample:

### How to opt in

```typescript
import {
  startSharedPrestashopContainer,
  PrestashopTestContainer,
} from '../helpers/prestashop-container.helper';

beforeAll(async () => {
  // Shared across every PS spec, booted on first use, stopped once in
  // globalTeardown from the ids the worker realm leaves on disk (#1920).
  // `cleanup()` is a no-op on this path.
  prestashop = await startSharedPrestashopContainer();
}, 15 * 60_000);

The shared container is always OL-module-installed and is not reset between
specs — assert only over OL-side results or over ids your own spec created.
Call startPrestashopContainer() directly ONLY when the spec's subject is the
from-scratch install itself (today: prestashop-webhook-provisioning).


### SUGGESTION 3 — `OL_SKIP_PS_MODULE_INSTALL=true` no longer skips the install for the smoke spec

`prestashop-container.helper.ts:1079` hard-codes `installOlModule: true` and never reads the env var; only the two specs' own `INSTALL_OL_MODULE` constants do, and the smoke spec calls the shared starter unconditionally. So with the hatch set you get three containers, one of which now pays the 23 s module install it never needed. The description's "in that mode a spec takes its own module-less container" is inaccurate for that spec.

```ts
export async function startSharedPrestashopContainer(): Promise<PrestashopTestContainer> {
  // Honour the same escape hatch the specs use, so `OL_SKIP_PS_MODULE_INSTALL=true`
  // really means "no module anywhere".
  const installOlModule = process.env.OL_SKIP_PS_MODULE_INSTALL !== 'true';
  ...
  const started = await startPrestashopContainer({ installOlModule, onStarted: … });

SUGGESTION 4 — the drain covers 1 of up to COUNT: 10 messages, and the batch loop never re-checks isRunning

webhook-to-job.handler.ts:229-236. XREADGROUP … COUNT 10 can return ten messages and the inner for has no shutdown check: ten read, stopConsumptionLoop awaits message 1 and returns, redisClient.quit() runs, messages 2..10 execute against a quitting client — each throwing and unACKed (PEL reclaim in production). The old flat sleep(2000) incidentally gave the batch ~2 s of wall clock. There is also a narrower window where xReadGroup has resolved but its continuation hasn't run, so inFlightMessage is still null and shutdown waits for nothing.

Benign in int-spec teardown (nothing in flight) and idempotent in production (route is keyed by event.eventId), hence not a blocker — but one line closes it:

          for (const message of streamMessage.messages) {
            // Don't start a new message once shutdown has been signalled - the
            // drain in stopConsumptionLoop only covers the one already running.
            if (!this.isRunning || this.abortController?.signal.aborted) {
              break;
            }
            this.inFlightMessage = this.processMessage(message.id, message.message);

SUGGESTION 5 — the shared-record scope key omits GITHUB_RUN_ATTEMPT, and the sweep deliberately skips its own run id

prestashop-container.helper.ts:1014 keys on GITHUB_RUN_ID, which is stable across job re-runs. If attempt 1 is SIGKILLed (cancel-in-progress, ci.yml:163), its PS container keeps running and its record file survives in /tmp; on a manual re-run the sweep hits if [ "$OWNER_RUN_ID" = "$GITHUB_RUN_ID" ]; then continue (ci.yml:239) and leaves it, then readLiveSharedRecord sees Running == true and reuses attempt 1's half-mutated PrestaShop DB.

function sharedRecordFile(): string {
  const scope = process.env.GITHUB_RUN_ID
    ? `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT ?? '1'}`
    : `local-${process.ppid}`;
  return join(tmpdir(), `ol-shared-ps-${scope}.json`);
}

Worth adding to that function's comment that the local ppid key silently depends on maxWorkers: 1 (see VERIFIED below) — a future bump would diverge the key and leak a container.

SUGGESTION 6 — the liveness docker inspect lacks stdio/timeout, unlike every sibling call

prestashop-container.helper.ts:1036-1042. On a stale record it prints Error: No such object: <id> to the job's stderr before the (correct) fallback, and an unresponsive Docker daemon blocks the first PS spec's beforeAll indefinitely. Every other execFileSync in the file passes stdio: 'ignore' and/or a timeout (:355, :1123).

    const state = execFileSync(
      'docker',
      ['inspect', '-f', '{{.State.Running}}', record.containers.prestashopId],
      { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 30_000 }
    ).trim();

SUGGESTION 7 — github.sha in the cache key mints a fresh multi-hundred-MB entry per commit

.github/workflows/ci.yml:190. Correctness is fine (prefix restore-keys + content-addressed ts-jest entries) and a unique key is what guarantees a save — but the repo-wide GH cache budget is 10 GB with LRU eviction, so per-commit jest caches will steadily evict the setup-node/pnpm store cache this same job depends on. A content-scoped key still hits on unchanged deps and saves far less often:

          key: jest-integration-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'apps/*/test/jest-integration.cjs') }}-${{ github.run_id }}
          restore-keys: |
            jest-integration-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'apps/*/test/jest-integration.cjs') }}-
            jest-integration-${{ runner.os }}-

Verified sound

Ran green: @openlinker/test-kit unit specs 10/10; connection-crud.int-spec.ts 9/9 against real Postgres+Redis (so the probe + pg_constraint walk + batched TRUNCATE execute through TypeORM, not only against the unit-test fake); webhook-to-job.handler.spec.ts 13/13 incl. all 5 drain specs; tsc --noEmit for test-kit clean.

The recursive CTE was attacked empirically on a scratch postgres:16-alpine with hostile FK shapes: self-referential FK and a true a↔b cycle terminate (the recursive term uses UNION, so Postgres dedups); the walk follows confrelid → conrelid, i.e. dependents only, so an FK whose parent is not in the list correctly does not pull the parent in — matching TRUNCATE … CASCADE, which never cascades upward; transitive dependents are found across a 3-level chain; a partitioned table returns both the partitioned parent and the leaf partition, both legal TRUNCATE targets.

The closure expansion genuinely restores the pre-change semantics, not just the cases the new tests cover: for any list L the reset probes closure(L) and truncates the dirty subset D; anything the old loop cleared is either in D or empty at probe time. Divergence candidates checked and ruled out: no RESTART IDENTITY on either side, and no TRUNCATE triggers in the schema.

The "closure is vacuous today" claim is accurate — re-derived independently: exactly 4 FK-producing relations in libs/core/src/**/*.orm-entity.ts, cross-referenced against the 19 entries in setup.ts:58; every dependent of a listed table is listed, and the attribute_* pair is not in the list at all.

The identifier assert cannot be bypassed — caller names are asserted before any SQL is built, resolveCascadeClosure re-asserts the Postgres-discovered dependents before caching, and the memo-hit path returns an already-asserted list. The WeakMap memo cannot go stale or leak (fresh module registry ⇒ fresh DataSource per spec file; the FK graph is fixed once synchronize runs).

The drain bound is respected and leaves no handles: Promise.race caps at exactly IN_FLIGHT_DRAIN_TIMEOUT_MS, the timer is cleared in finally, .catch() prevents an unhandled rejection while consumeLoop's own try/catch keeps owning redelivery. Nothing the old sleep covered is newly dropped — the only regression is the window in SUGGESTION 4, on an idempotent eventId-keyed path.

The realm-mismatch leak I suspected does not exist. sharedRecordFile()'s local-${process.ppid} key was attacked specifically: with maxWorkers: 1 Jest runs in-band, and a throwaway Jest project confirms the test realm and globalTeardown report identical pid and ppid. The file-not-env-var handoff reasoning in the code comment is correct.

"No PS-side cleanup between the sharing specs" holds — including an axis the description doesn't argue. Every assertion re-read: carrier-mapping asserts over OL-side results, fulfillment-update filters by its own psOrderId, smoke filters by external_module_name=openlinker. Beyond that, re-application of PS fixtures by a second spec's beforeAll is also safe: getDefaultPsCarriers only UPDATEs, seedSecondaryTestCarrier fires only when one carrier exists, ensureCarrierFullyDelivered DELETEs-then-reseeds ps_delivery/ps_range_* (no duplicate delivery rows to skew the total_shipping == 12.50 assertion), and its external_module_name = '' write is name-scoped to My carrier/My cheap carrier, never the openlinker row the smoke spec asserts on.

Shared-container lifecycle is sound: onStarted fires only after verifyApacheUp, so a failed boot records nothing; the !containers guard cleans up and throws rather than leaking; stopSharedPrestashopContainer is fully synchronous so it completes before forceExit, and reuses the removePsDataDir root-container path required by #1321. Cross-run stale reuse is impossible (only the same-run re-run case in SUGGESTION 5 slips through), and concurrent jobs from different runs get different record files.

Persisting the jest cache is safe: ts-jest entries are content-addressed (plus version/tsconfig), so a restored older cache misses rather than serving stale output; .gitignore covers .jest-cache/. Test quality is good — the drain specs assert behaviour under fake timers and genuinely fail on a revert (a restored setTimeout(2000) never resolves without an advance), and the closure spec drives the fake with a Postgres-shaped response instead of asserting the walk's SQL text.

Not verified

  • The four PS int-specs under the shared container were not executed here (~130 s + a ~1 GB image + MySQL companion). The ~380 s → 132.3 s and 85 suites / 501 tests / 431.2 s figures rest on the author's local run plus the linked CI runs. What was verified is the reasoning behind the no-cleanup claim, which is where the risk lives.
  • Every timing in the description (the 41.5 → 19.2 → 10.1 s A/B, ~207 ms → ~13 ms, 6.42 s warm vs 76.29 s cold, the CI step deltas) — single-machine numbers, not reproducible without a comparable idle runner. The mechanism behind each was verified instead.
  • OL_SKIP_PS_MODULE_INSTALL=true end-to-end (SUGGESTION 3 is from reading the path, not booting it), behaviour under maxWorkers > 1 (nothing exercises it today), and the actual GH cache-size impact in SUGGESTION 7.

Note on the CI numbers for this head

Latest green run is 30521664997 (all 8 checks pass): whole job 13m50s vs the 18m44s baseline, api step 556 s vs 883.8 s. That is worse than the 10m01s idle-runner figure in the description for three reasons worth recording: 4 concurrent CI runs started in the same 5-minute window; merging main partially invalidated the ts-jest cache; and the suite grew since the baseline (api 84 → 86 suites / 492 → 504 tests, worker 13 → 14 / 38 → 40). The per-suite evidence is unambiguous either way — prestashop-order-fulfillment-update 99.6 s → 5.3 s, prestashop-harness-smoke 47.9 s → under the 5 s print threshold, and only 12 of 86 api suites now report a duration at all versus 81 of 81 in the baseline. The worker step's +12 s is not this PR: its first file is main's new master-inventory-deletion-e2e at 40.7 s absorbing the cold-transform tax, against ~10 s for comparable suites (the baseline shows the same shape with a different first file).

…view findings (#1923)

IMPORTANT 1 - the closure walk's recursive term had no schema guard. Only the
`requested` CTE filtered on `current_schemas(false)`, so a table in another
schema holding an FK to a listed table entered the closure and was then probed
by its BARE name. Because the probe is one `UNION ALL` statement, the resulting
`relation "x" does not exist` failed every `afterEach` in the suite - strictly
louder than the pre-#1920 per-table loop, which never touched that table at
all. The recursive term now joins pg_class/pg_namespace and applies the same
`current_schemas(false)` filter to the DEPENDENT side. Reproduced and fixed
against a scratch postgres:16-alpine: with `other.xref -> public.parent`, the
old query returns `xref`, the new one does not, while a transitive dependent
(`parent -> child -> grandchild`) and a self-referential FK still behave.
Cross-schema dependents are ignored rather than schema-qualified: the reset is
scoped to the schema under test.

IMPORTANT 2 - the PrestaShop section of docs/testing-guide.md still documented
the per-spec boot this branch replaced, in four places (the opt-in sample, the
"suite-scoped, one boot per int-spec file" claim, the who-opts-in list, and the
vertical-slice guidance). A contributor following it would have booted a fourth
container and re-added ~78 s. It now describes the run-scoped shared container,
the two rules a spec must respect (nothing resets PS between specs; the shared
container is always module-installed), and when to take your own container.

SUGGESTION 3 - `startSharedPrestashopContainer` hard-coded `installOlModule:
true` and never read `OL_SKIP_PS_MODULE_INSTALL`, so with the hatch set the
smoke spec booted a shared container that paid the 23 s install it did not
need. It now honours the same env var the individual specs read.

SUGGESTION 4 - `XREADGROUP ... COUNT 10` can return a whole batch, but the
shutdown drain covers only the message already running: messages 2..N could
start against a quitting Redis client. The batch loop now breaks once shutdown
is signalled.

SUGGESTION 5 - `sharedRecordFile()` keyed on `GITHUB_RUN_ID`, which is stable
across "Re-run jobs" while the orphan sweep deliberately skips its own run id -
so a re-run could adopt a SIGKILLed attempt's half-mutated PrestaShop DB. The
key now carries `GITHUB_RUN_ATTEMPT`, and the comment records that the local
`ppid` key only lines the two realms up because both integration configs pin
`maxWorkers: 1`.

SUGGESTION 6 - the liveness `docker inspect` lacked the `stdio`/`timeout` every
sibling `execFileSync` in the file passes, so a stale record printed a Docker
error into the job log and an unresponsive daemon could hang the first PS
spec's `beforeAll` indefinitely.

SUGGESTION 7 - the jest-cache key used `github.sha`, minting a fresh
multi-hundred-MB entry per commit, which would evict the pnpm store cache the
same job restores (10 GB repo budget, LRU). It is now scoped to what
invalidates the cache wholesale (lockfile, base tsconfig, both jest configs)
plus the run id, so a save still happens while unchanged-deps runs share one
entry.

Verification: test-kit unit specs 11/11 (1 new - the dependent-side schema
guard); webhook handler specs 15/15 (2 new - the loop stops starting messages
after shutdown, and still drains a full batch while running); `connection-crud`
int-spec 9/9 against real Postgres; eslint clean on the gated paths;
type-check clean for @openlinker/test-kit and @openlinker/api; ci.yml parses
and the `apps/*/test/jest-integration.cjs` glob matches both configs.

Refs #1920

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

All seven second-pass findings addressed — 1e9e5ecf

# Finding What changed
1 (IMPORTANT) closure walk's recursive term had no schema guard recursive term now joins pg_class/pg_namespace and applies current_schemas(false) to the dependent side
2 (IMPORTANT) PS section of docs/testing-guide.md still documented the per-spec boot opt-in sample, the "suite-scoped" claim, the who-opts-in list and the vertical-slice guidance rewritten for the run-scoped shared container
3 startSharedPrestashopContainer ignored OL_SKIP_PS_MODULE_INSTALL now reads the same env var the individual specs read
4 batch loop never re-checked shutdown break once isRunning is false / the abort signal fired
5 record key omitted GITHUB_RUN_ATTEMPT key carries the attempt; comment records the maxWorkers: 1 dependency of the local ppid key
6 liveness docker inspect lacked stdio/timeout stdio: ['ignore','pipe','ignore'] + timeout: 30_000, matching the house style at :355/:1123
7 jest cache key used github.sha key scoped to lockfile + base tsconfig + both jest configs, plus github.run_id

Finding 1 — reproduced, then fixed, against a scratch postgres:16-alpine

Schema fixture: public.parent, public.child(p → parent), public.grandchild(c → child), public.selfref(s → selfref), and other.xref(p → public.parent). Requested list: ['parent','selfref'].

=== NEW query (schema-guarded recursive term) ===
 child | grandchild | parent | selfref            (4 rows)

=== OLD query (no guard) ===
 child | grandchild | parent | selfref | xref     (5 rows)

xref is the bug: it was pulled into the closure and then probed by its bare name, which is what took the whole afterEach down (relation "xref" does not exist) — one UNION ALL statement, so a single unreachable relation fails the reset for every table. Transitive dependents and the self-referential FK still behave, so the guard did not narrow anything it should keep.

Design note: cross-schema dependents are ignored, not schema-qualified. The reset is scoped to the schema under test, and a dependent outside search_path is not something an int-spec's TRUNCATE should reach into.

Finding 4 — the window is real but was benign

Kept as a SUGGESTION-level fix, not re-litigated: the pre-existing behaviour was safe in both directions that matter (int-spec teardown has nothing in flight; production route is keyed by event.eventId, so a redelivered message is idempotent). The guard removes the case where messages 2..N of a COUNT 10 batch start against a client that quit() has already been called on.

Verification

  • @openlinker/test-kit unit specs 11/11 — 1 new: the walk constrains the dependent side to the search path
  • webhook handler specs 15/15 — 2 new: the loop stops starting messages after shutdown is signalled mid-batch, and still drains a full batch while running
  • connection-crud.int-spec.ts 9/9 against real Postgres — the reworked SQL executes through TypeORM, not only against the unit-test fake
  • eslint clean on the lint-gated paths (libs/test-kit/src, apps/api/src/webhooks); type-check clean for @openlinker/test-kit and @openlinker/api
  • ci.yml parses, and the apps/*/test/jest-integration.cjs glob was confirmed to match both integration configs (api + worker)

Two things deliberately left alone: the pre-existing consistent-type-imports / unused-destructure lint noise in prestashop-container.helper.ts (that path is outside the src/** lint gate, and touching it is unrelated churn), and the docs/lessons.md entry, which still reads correctly after these changes.

Resolves the docs/lessons.md conflict by keeping both ledger entries: the
#1920 harness-profiling lesson from this branch and the three #1487 entries
from main. The ledger is append-only, so the conflict was purely positional.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve.

Full review in this comment.

My one finding — that skipping empty tables also skips their CASCADE side effects, which could resurrect the order-dependent-flake class this repo has already been bitten by — was addressed properly rather than asserted away. resolveCascadeClosure expands the caller's list to its real FK closure and memoises per DataSource, so the optimisation and the old semantics both survive. assertSafeTableNames picked up the optional suggestion too.

The measurements are what made this reviewable: recording that batching was tried and is not the win (18 statements 280 ms vs one combined 215 ms) stops the next person re-attempting it.

@piotrswierzy
piotrswierzy merged commit 8401f3f into main Jul 30, 2026
8 checks passed
@piotrswierzy
piotrswierzy deleted the 1920-integration-suite-speedups branch July 30, 2026 19:56
piotrswierzy added a commit that referenced this pull request Aug 20, 2026
…2164)

Review follow-ups on the recovery primitives. Two were real defects.

reclaimOrphans discarded the XCLAIM reply and re-read the body with XRANGE.
XCLAIM without FORCE cannot resurrect a non-pending id, so there was no PEL
leak — but the opposite failure existed: when a claim legitimately does not
transfer (the owner ACKed between the XPENDING and the XCLAIM, or touched the
entry so it is no longer idle past the threshold), XRANGE still returns the
body, because ACK removes an entry from the PEL but not from the stream. The
code then processed and ACKed a message belonging to another live consumer —
defeating the very re-assertion the comment claimed to make. The claim reply is
now the source of truth: node-redis returns one element per requested id and
null where the claim did not transfer, so a non-transfer is either a lost race
(skip; not ours) or a trimmed entry (report so the caller clears the dangling
id), disambiguated by a single XRANGE. The unit spec previously mocked
xClaim as resolving undefined while asserting an entry came back — it encoded
the bug — and now pins the correct behaviour, with an integration test covering
the owner-ACKed-first race against real Redis.

maybeReclaimOrphans sat after the loop's empty-batch `continue` in all three
consumers, so it only ran when a batch arrived. Orphans are reclaimed precisely
when a stream is quiet — events.master.deletion can sit empty for days — so the
feature was dead code in the case it exists for. Moved above the check; the
existing lastReclaimAt throttle already prevents it firing every block timeout.

Also:

- The periodic pass now re-drains own pending before the orphan sweep. A
  handler that throws leaves its entry un-ACKed in this consumer's PEL, and the
  orphan sweep deliberately skips self-owned rows, so a single transient
  failure previously stranded a message until the process restarted.
- The webhook handler's recovery path now tracks inFlightMessage and honours
  the abort flag, as the batch path does. Without it a shutdown mid-recovery
  returned from stopConsumptionLoop immediately and onModuleDestroy quit the
  client out from under a message still being processed — the regression #1923
  fixed, reintroduced on a new path.
- The startup drain is bounded by MAX_DRAIN_PAGES and bails on abort. Its
  termination previously depended on an ACK-or-throw invariant held across
  three files with no test; a future non-ACKing branch would have hung
  onModuleInit and with it application boot.
- RECLAIM_INTERVAL_MS moved to the shared module rather than being redeclared
  per consumer.
- Corrected docs and comments that described XAUTOCLAIM and an `id: '0'` read.
  Neither is used, and the module header explains why — an ADR whose purpose is
  audit-trail accuracy must not misstate its own implementation.
- Documented OL_WORKER_ID in both .env.example files: it is a
  correctness-relevant knob (two replicas sharing a value share one PEL) that
  existed only in the plan.

Rebased onto current main.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
piotrswierzy added a commit that referenced this pull request Aug 20, 2026
…2164)

Review follow-ups on the recovery primitives. Two were real defects.

reclaimOrphans discarded the XCLAIM reply and re-read the body with XRANGE.
XCLAIM without FORCE cannot resurrect a non-pending id, so there was no PEL
leak — but the opposite failure existed: when a claim legitimately does not
transfer (the owner ACKed between the XPENDING and the XCLAIM, or touched the
entry so it is no longer idle past the threshold), XRANGE still returns the
body, because ACK removes an entry from the PEL but not from the stream. The
code then processed and ACKed a message belonging to another live consumer —
defeating the very re-assertion the comment claimed to make. The claim reply is
now the source of truth: node-redis returns one element per requested id and
null where the claim did not transfer, so a non-transfer is either a lost race
(skip; not ours) or a trimmed entry (report so the caller clears the dangling
id), disambiguated by a single XRANGE. The unit spec previously mocked
xClaim as resolving undefined while asserting an entry came back — it encoded
the bug — and now pins the correct behaviour, with an integration test covering
the owner-ACKed-first race against real Redis.

maybeReclaimOrphans sat after the loop's empty-batch `continue` in all three
consumers, so it only ran when a batch arrived. Orphans are reclaimed precisely
when a stream is quiet — events.master.deletion can sit empty for days — so the
feature was dead code in the case it exists for. Moved above the check; the
existing lastReclaimAt throttle already prevents it firing every block timeout.

Also:

- The periodic pass now re-drains own pending before the orphan sweep. A
  handler that throws leaves its entry un-ACKed in this consumer's PEL, and the
  orphan sweep deliberately skips self-owned rows, so a single transient
  failure previously stranded a message until the process restarted.
- The webhook handler's recovery path now tracks inFlightMessage and honours
  the abort flag, as the batch path does. Without it a shutdown mid-recovery
  returned from stopConsumptionLoop immediately and onModuleDestroy quit the
  client out from under a message still being processed — the regression #1923
  fixed, reintroduced on a new path.
- The startup drain is bounded by MAX_DRAIN_PAGES and bails on abort. Its
  termination previously depended on an ACK-or-throw invariant held across
  three files with no test; a future non-ACKing branch would have hung
  onModuleInit and with it application boot.
- RECLAIM_INTERVAL_MS moved to the shared module rather than being redeclared
  per consumer.
- Corrected docs and comments that described XAUTOCLAIM and an `id: '0'` read.
  Neither is used, and the module header explains why — an ADR whose purpose is
  audit-trail accuracy must not misstate its own implementation.
- Documented OL_WORKER_ID in both .env.example files: it is a
  correctness-relevant knob (two replicas sharing a value share one PEL) that
  existed only in the plan.

Rebased onto current main.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
piotrswierzy added a commit that referenced this pull request Aug 20, 2026
…2164)

Review follow-ups on the recovery primitives. Two were real defects.

reclaimOrphans discarded the XCLAIM reply and re-read the body with XRANGE.
XCLAIM without FORCE cannot resurrect a non-pending id, so there was no PEL
leak — but the opposite failure existed: when a claim legitimately does not
transfer (the owner ACKed between the XPENDING and the XCLAIM, or touched the
entry so it is no longer idle past the threshold), XRANGE still returns the
body, because ACK removes an entry from the PEL but not from the stream. The
code then processed and ACKed a message belonging to another live consumer —
defeating the very re-assertion the comment claimed to make. The claim reply is
now the source of truth: node-redis returns one element per requested id and
null where the claim did not transfer, so a non-transfer is either a lost race
(skip; not ours) or a trimmed entry (report so the caller clears the dangling
id), disambiguated by a single XRANGE. The unit spec previously mocked
xClaim as resolving undefined while asserting an entry came back — it encoded
the bug — and now pins the correct behaviour, with an integration test covering
the owner-ACKed-first race against real Redis.

maybeReclaimOrphans sat after the loop's empty-batch `continue` in all three
consumers, so it only ran when a batch arrived. Orphans are reclaimed precisely
when a stream is quiet — events.master.deletion can sit empty for days — so the
feature was dead code in the case it exists for. Moved above the check; the
existing lastReclaimAt throttle already prevents it firing every block timeout.

Also:

- The periodic pass now re-drains own pending before the orphan sweep. A
  handler that throws leaves its entry un-ACKed in this consumer's PEL, and the
  orphan sweep deliberately skips self-owned rows, so a single transient
  failure previously stranded a message until the process restarted.
- The webhook handler's recovery path now tracks inFlightMessage and honours
  the abort flag, as the batch path does. Without it a shutdown mid-recovery
  returned from stopConsumptionLoop immediately and onModuleDestroy quit the
  client out from under a message still being processed — the regression #1923
  fixed, reintroduced on a new path.
- The startup drain is bounded by MAX_DRAIN_PAGES and bails on abort. Its
  termination previously depended on an ACK-or-throw invariant held across
  three files with no test; a future non-ACKing branch would have hung
  onModuleInit and with it application boot.
- RECLAIM_INTERVAL_MS moved to the shared module rather than being redeclared
  per consumer.
- Corrected docs and comments that described XAUTOCLAIM and an `id: '0'` read.
  Neither is used, and the module header explains why — an ADR whose purpose is
  audit-trail accuracy must not misstate its own implementation.
- Documented OL_WORKER_ID in both .env.example files: it is a
  correctness-relevant knob (two replicas sharing a value share one PEL) that
  existed only in the plan.

Rebased onto current main.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
piotrswierzy added a commit that referenced this pull request Aug 20, 2026
…K + ADR-049 durability spine (#2223)

* fix(core): recover Redis stream messages stranded between read and ACK (#2164)

Every consumer read with `id: '>'` — never-delivered entries only — and the
repo contained zero calls to XPENDING/XCLAIM/XAUTOCLAIM, so no code path ever
read a Pending Entries List. A process killed between read and ACK therefore
lost its in-flight message permanently, not temporarily. The comment at
job-intake.consumer.ts asserted "message will be re-delivered after timeout";
Redis has no such timeout, which is why this went unnoticed.

On the webhook path the loss was also invisible: `webhook_deliveries` is
stamped `job_enqueued` before the ACK, so a dropped order was indistinguishable
from a delivered one.

Three primitives in `@openlinker/shared/redis`, wired into all three consumer
groups (webhook-handler, master-deletion-offer-pause, job-intake):

- Stable consumer identity via `resolveConsumerName` (OL_WORKER_ID, else
  hostname). `${prefix}-${process.pid}` was wrong in both directions: in a
  container PID is typically 1, so replicas collided on one PEL; outside one
  the name changed every restart, so a process could not reach its own history.
  This is the precondition for everything else.
- Startup drain of own pending history before switching to new messages.
- Periodic orphan reclaim for work stranded by a replica that never returned,
  with the idle threshold floored well above p99 handler duration — a reclaim
  that fires early steals live work and double-runs it.

Built on XPENDING + XCLAIM + XRANGE rather than XREADGROUP/XAUTOCLAIM. This is
load-bearing, not stylistic: node-redis v1.5.x transforms an XREADGROUP reply
through `transformTuplesReply`, which calls `.length` on the field array, so an
entry trimmed while its id remained in the PEL makes the client library throw a
TypeError before any of our code runs — aborting the drain and leaving the id
unackable, permanently blocking that consumer's recovery. XRANGE returns an
empty array for a missing id, which is an answer rather than a crash. A trimmed
entry is classified as its own outcome and ACKed, never routed into a handler's
error path, where it would persist a bogus dead sync_jobs row or a dead-letter
entry describing a failure that never happened.

Also guards `persistDeadJob`'s unconditional `markDead`: drain and reclaim make
redelivery real, so a redelivered message whose job has since run would
otherwise flip a live row to 'dead'.

Everything holds to the Redis 6.2 command floor, so #1396's Valkey swap stays a
retag rather than a redesign.

Covered by 20 unit tests and 9 Testcontainers integration tests, including the
crash-then-restart case the issue names and the trimmed-PEL path.

Closes #2164

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

* docs(adr): ADR-049 durability spine and the domain-event contract (#2165)

OL has three "event" streams and zero fan-out: each has at most one consumer,
each consumer does exactly one thing (turn the event into a job), and
events.sync.jobs has no consumer at all. By the standard test — if the producer
expects a specific outcome, it is a command wearing event clothing — all three
are commands. The streams buy indirection, not decoupling. Meanwhile durability
sits after four hops on the webhook path.

Nine decisions, most of which are a decision NOT to build something, each with
an observable reversal gate rather than an argued one:

1. The spine is the work row, written in the same transaction as the business
   change — so the outbox and the queue are the same table.
2. Build the contract; keep one transport. Do not build a general bus at zero
   fan-out. Gate: the first stream to acquire a second independent consumer.
3. If a bus: composite cursor plus a visibility barrier, never a scalar
   `id > cursor`.
4. eventId is derived from the business fact, never minted at insert.
5. No EntityManager in a core port signature.
6. Payload schemas are structurally incapable of carrying PII — redaction on
   write fails open, structural exclusion fails closed.
7. Registration-time catalog validation, not a central type union that would
   invert the infrastructure spine.
8. Nothing depends on a stream primitive above the Redis 6.2 floor, so #1396's
   Valkey swap stays a retag.
9. Redis is never the sole record of a fact — stated because it is currently
   false in at least three places.

Numbered 049 per the #2162 reallocation: 046 was claimed by #2203 and 047 by
reserved-numbers note to the #2166 branch that lands first.

Also adds the docs/lessons.md entry for the commit-order gap that decision 3
rests on: an id is assigned before its transaction commits, so id order is not
visibility order, and a reader advancing a scalar cursor past a gap never sees
the earlier row again — silently and permanently.

Closes #2165

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

* fix(core): trust the XCLAIM reply and run reclaim on an idle stream (#2164)

Review follow-ups on the recovery primitives. Two were real defects.

reclaimOrphans discarded the XCLAIM reply and re-read the body with XRANGE.
XCLAIM without FORCE cannot resurrect a non-pending id, so there was no PEL
leak — but the opposite failure existed: when a claim legitimately does not
transfer (the owner ACKed between the XPENDING and the XCLAIM, or touched the
entry so it is no longer idle past the threshold), XRANGE still returns the
body, because ACK removes an entry from the PEL but not from the stream. The
code then processed and ACKed a message belonging to another live consumer —
defeating the very re-assertion the comment claimed to make. The claim reply is
now the source of truth: node-redis returns one element per requested id and
null where the claim did not transfer, so a non-transfer is either a lost race
(skip; not ours) or a trimmed entry (report so the caller clears the dangling
id), disambiguated by a single XRANGE. The unit spec previously mocked
xClaim as resolving undefined while asserting an entry came back — it encoded
the bug — and now pins the correct behaviour, with an integration test covering
the owner-ACKed-first race against real Redis.

maybeReclaimOrphans sat after the loop's empty-batch `continue` in all three
consumers, so it only ran when a batch arrived. Orphans are reclaimed precisely
when a stream is quiet — events.master.deletion can sit empty for days — so the
feature was dead code in the case it exists for. Moved above the check; the
existing lastReclaimAt throttle already prevents it firing every block timeout.

Also:

- The periodic pass now re-drains own pending before the orphan sweep. A
  handler that throws leaves its entry un-ACKed in this consumer's PEL, and the
  orphan sweep deliberately skips self-owned rows, so a single transient
  failure previously stranded a message until the process restarted.
- The webhook handler's recovery path now tracks inFlightMessage and honours
  the abort flag, as the batch path does. Without it a shutdown mid-recovery
  returned from stopConsumptionLoop immediately and onModuleDestroy quit the
  client out from under a message still being processed — the regression #1923
  fixed, reintroduced on a new path.
- The startup drain is bounded by MAX_DRAIN_PAGES and bails on abort. Its
  termination previously depended on an ACK-or-throw invariant held across
  three files with no test; a future non-ACKing branch would have hung
  onModuleInit and with it application boot.
- RECLAIM_INTERVAL_MS moved to the shared module rather than being redeclared
  per consumer.
- Corrected docs and comments that described XAUTOCLAIM and an `id: '0'` read.
  Neither is used, and the module header explains why — an ADR whose purpose is
  audit-trail accuracy must not misstate its own implementation.
- Documented OL_WORKER_ID in both .env.example files: it is a
  correctness-relevant knob (two replicas sharing a value share one PEL) that
  existed only in the plan.

Rebased onto current main.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

* fix(core): stop one poison entry blocking recovery of every stranded sibling (#2164)

Review findings on the recovery loops. The first is a real defect in this PR.

A HANDLER THROW ABORTED THE WHOLE RECOVERY PASS, PERMANENTLY.

`handleRecoveredEntry` delegates to `processMessage`, which rethrows on any
non-decode error, and the drain's try/catch spanned the entire page loop — so a
single entry whose handler threw aborted the pass and returned. Because
`readOwnPending` always pages the PEL from the oldest id, that same entry then
led every later drain and every reclaim, and it was never ACKed, so it stayed
first forever. One poison message permanently starved recovery of every other
stranded message: precisely the failure this recovery path exists to prevent.

Each entry now runs inside its own try/catch (`recoverEntrySafely`), so a
handler failure logs and the pass continues to the next entry. The failing entry
stays un-ACKed and is retried on a later pass; what no longer happens is its
siblings being blocked behind it. The outer catch is kept for genuine
Redis-level failures. Applied to all three consumers and to both the drain and
the reclaim path.

REPEATED DELIVERY IS NOW REACHABLE, SO IT NEEDS A SIGNAL.

Before #2164 a failing entry was simply never redelivered, so no terminal state
was needed; recovery is what makes unbounded retry possible. `XPENDING` already
reports a delivery counter and `toPendingRows` was discarding it. It is now
surfaced as `PendingRow.deliveryCount`, threaded onto `StreamEntry`, and
compared against `MAX_DELIVERY_ATTEMPTS` — crossing it logs at `error` naming
the entry as needing intervention. The constant is deliberately generous: it is
a terminal-state backstop, not a retry budget, so a handler failing on a
transient blip is still allowed to succeed later. A missing counter defaults to
1, never 0, so a reply-shape change cannot make an entry read as never
delivered.

Auto-dead-lettering is deliberately NOT done, and the reason is recorded in
ADR-049 with a reversal gate: two of the three consumers cannot construct their
dead-letter payload from a raw pending entry (the webhook handler needs a
decoded event, job-intake a parsed job request), and discarding the entry
instead would be unrecoverable loss. With per-entry isolation in place the
absence of a terminal state no longer blocks anything — it is noise plus a
missing alarm, and the alarm now exists.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

* fix(core): make recovery terminate, and alarm on a counter that can actually move (#2164)

Review findings on the previous commit. Both are defects it introduced.

THE DRAIN NO LONGER TERMINATED, AND onModuleInit AWAITS IT.

Isolating a handler failure per entry removed the only thing that ended the
startup drain: previously the throw escaped to the outer catch and returned.
`readOwnPending` always paged the PEL from '-', and a failing entry is never
ACKed, so the same page came back every iteration and `entries.length === 0`
became unreachable. The abort escape does not help — `abortController` is
created by `startConsumptionLoop()`, which runs *after* the awaited drain. So
one poison entry meant MAX_DRAIN_PAGES (1000) iterations of up to COUNT (10)
handler invocations plus their Redis round-trips, blocking Nest bootstrap, and
`drained` reported ~10,000 recovered messages for one stuck entry.

The drain now pages forward with an exclusive `(<id>` cursor (Redis 6.2, the
same floor XPENDING sits on), so a pass visits each entry once and terminates
naturally. A failed entry stays pending for the next recovery pass rather than
being re-attempted inside the current one.

THE POISON ALARM COULD NEVER FIRE WHERE POISON LIVES.

The previous commit keyed the alarm on Redis' `deliveriesCounter`. Redis
increments that on delivery — XREADGROUP and XCLAIM — never on XPENDING or
XRANGE, which is all the drain path uses. Re-presenting an entry a thousand
times therefore left the counter at 1, so the branch was unreachable on exactly
the path where a stuck handler accumulates. The reclaim path increments once,
then `owner !== consumer` excludes the entry from every later pass, freezing it
at 2. Reaching the threshold needed eleven distinct consumer identities each
abandoning the entry — not a thing that happens on a stable-hostname deployment,
which is the point of `resolveConsumerName`.

Worse, the integration test added alongside it asserted the counter rises by
calling XCLAIM directly. True, and irrelevant to the drain — it would have gone
on passing while production never incremented.

Counting is now local: a per-consumer `RecoveryAttemptTracker` keyed by entry
id, incremented on the failure path, cleared on success so a transient failure
does not leave an entry permanently near the threshold. It fires once, on the
crossing, because a poison entry recurs by definition and an unguarded alarm per
pass is fatigue on the channel meant to carry real incidents. Redis'
`deliveryCount` is kept as diagnostic context — genuinely useful for
cross-replica churn — with its limits and its reclaim-path off-by-one documented
on the field.

Also:

- `recoverEntrySafely` rethrows when the abort signal is set, so a shutdown-time
  failure ends the pass instead of grinding the rest of the page against a
  quitting client.
- The head-of-line fix is now pinned by integration tests, which is what the
  review said was missing and what would have caught the non-termination above:
  a page whose first entry is never ACKed still reaches its siblings; the scan
  ends rather than re-reading; and Redis' own counter is asserted frozen across
  drain re-reads, which is the evidence for counting locally.
- ADR-049's known-gap paragraph corrected — it presented the delivery-count
  alarm as the compensating control.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

* fix(core): stop the recovery log over-reporting, and correct the reclaim delivery count (#2164)

Two remaining review findings.

`drained += entries.length` counted entries ATTEMPTED, not entries handled. The
exclusive-cursor fix removed the 1000x inflation, but a pass that failed on
three of a ten-entry page still logged "Recovered 10 pending message(s)". That
line is read by an operator during the incident it is describing, so
over-reporting there is the same class of dishonesty this epic exists to remove.
`recoverEntrySafely` now reports whether the entry was handled and the drain
counts only successes.

The reclaim path's `reclaimed` count is deliberately left as-is: it reports what
`reclaimOrphans` actually did — take ownership of N entries — which is true
independently of whether handling then succeeded.

`deliveryCount` on the reclaim path was stale by exactly one: the XPENDING
listing is read before that pass's own XCLAIM, and XCLAIM is itself a delivery.
Now compensated at the point of construction rather than only described in the
field's doc comment.

Still open, deliberately, and listed so they are not mistaken for oversights:
the `trimmed` check is expressed in two places (`resolvePendingEntry` and the
reclaim branch), `recoverEntrySafely` is triplicated across the three consumers
(the attempt counter it depends on is already single-source in
`RecoveryAttemptTracker`, which was the load-bearing part), and the two shared
Redis modules colocate their types rather than using `*.types.ts` — a deviation
from the written standard that matches roughly half of `libs/shared` today.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

* fix(core): report recovery honestly, and hold the shutdown guarantee the code claimed (#2164)

Six review findings. Two are defects in the immediately preceding commit; one
contradicts a claim that commit's own message made.

A DISCARDED ENTRY WAS COUNTED AND LOGGED AS RECOVERED.

The previous commit made the drain count successes instead of attempts, using a
boolean. A `trimmed` entry ACKs successfully — its payload is gone, destroyed by
retention — so it returned true and was counted. A boot finding ten stranded
webhook events whose payloads had all been trimmed logged "Recovered 10 pending
webhook event(s)". That is worse than the over-count it replaced: it reports
permanent loss as successful recovery, on the path where lost work is a dropped
order whose delivery row still reads `published`.

A boolean cannot express three outcomes, which is why it was wrong on first use.
`recoverEntrySafely` now returns `RecoveryOutcome` — recovered / discarded /
failed — and the drain counts each, reporting discards separately and at warn.

`reclaimed` COUNTED ENTRIES THAT WERE NEVER CLAIMED.

The previous commit deliberately left this alone and justified it: "it reports
what `reclaimOrphans` actually did — take ownership of N entries". That is false
against the code. `reclaimOrphans` also returns a `trimmed` entry on the path
where XCLAIM did NOT transfer and XRANGE then found the data gone; no ownership
was taken there. Now counts only entries whose claim actually transferred.

THE SHUTDOWN GUARDS WERE BOTH DEAD ON THE PATH THAT NEEDED THEM.

`onModuleInit` awaits the drain and only then calls `startConsumptionLoop`,
which is where `abortController` was created. So during the startup drain — the
one pass that can run for many pages — both the loop's abort check and
`recoverEntrySafely`'s shutdown rethrow were reading an undefined controller and
could never fire. A shutdown arriving mid-drain was invisible to it, which is
exactly what the rethrow's comment says it prevents. The controller is now
created before the drain, and `startConsumptionLoop` replaces it only when a
previous run aborted (the restart-after-backoff path).

Also:

- `pending-retry` still had the head-of-line defect the drain was fixed for: it
  re-read the oldest COUNT ids from '-' every tick with no cursor, so poison at
  the head starved every later own-pending entry for the process lifetime. Now
  paged with the same exclusive cursor, capped per tick so recovery cannot
  monopolise the consume loop.
- The attempt tracker evicted the id that had been stuck LONGEST, because a Map
  `set` on an existing key does not reorder. Now delete-then-set, making
  eviction least-recently-failed.
- The alarm fired at MAX_RECOVERY_ATTEMPTS + 1 while the constant is documented
  as the threshold. Now fires at the documented value.
- Redis' `deliveryCount` had no production reader; it is now in the failure log
  line, which is where an operator distinguishing cross-replica churn from a
  locally-stuck handler would look.
- The frozen-counter integration assertion compared the readings to themselves,
  so a server reporting 0 every time would have passed while breaking the
  "defaults to 1, never 0" invariant. Now asserts the absolute value.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

* test(worker): pin the recovery-outcome contract that reported loss as recovery (#2164)

The previous round fixed the defect but not the gap that let it ship. The
review's finding was specifically that the drain's counting logic has no test —
"shipping it with no test means B1 was undetectable and a future regression back
to \`drained += entries.length\` is equally undetectable" — and the tracker unit
tests added instead cover a different thing.

Four cases on the consumer itself: a processed entry reports 'recovered', a
trimmed entry reports 'discarded' (never 'recovered' — its payload is gone), a
throwing handler reports 'failed' rather than propagating and aborting the pass
for its siblings, and a shutdown-time failure rethrows instead of being
swallowed as a handler error.

Verified by injection rather than assumed: reverting the trimmed branch to
return 'recovered' fails the second case, and only that case. A test that cannot
fail is the same mistake as the self-referential assertion this round already
corrected.

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

---------

Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.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.

[TECH-DEBT] DX - cut the ~18.5 min Integration Tests job: measured shutdown sleep, duplicated PrestaShop installs, cold jest transform cache

2 participants