From dce7084e68128cb1ff49dcda31ec2613466457c3 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 6 Jul 2026 16:49:37 -0600 Subject: [PATCH] test(integration): de-flake computed indexed properties REST block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Computed indexed properties' suite exercises jsTotalPrice, a '@computed @indexed' attribute whose resolver is registered at runtime by resources.js (setComputedAttribute) as per-worker in-memory state (Table.ts userResolvers), not at schema-load time. The /Product/ route (from the @export'd table) starts serving as soon as the schema loads, which can be before resources.js finishes on a worker. Harper runs many http workers (default cpus-1) and load-balances via SO_REUSEPORT, so a request can hit a still-cold worker: - a PUT on a cold worker indexes jsTotalPrice as undefined (frozen at write time), so the ?jsTotalPrice=119 filter misses the record; and - a GET ?select(jsTotalPrice) on a cold worker recomputes null. Either surfaces as an intermittent failure in the REST block (seen on Integration Tests 5/6, Node v26). Fix: gate the suite (before all reads/writes) on the jsTotalPrice resolver being live on every worker — poll on-read bursts (fresh connections spread across workers via Connection: close) until several consecutive bursts see zero cold responses. The route probe alone only proves the table is reachable on one worker. Stays multi-worker (functional test, not a single-thread special case). Verified on a 19-worker box by deferring the resolver registration 4s: the ungated write+filter fails deterministically (3/3), the gated version passes (3/3); the unmodified suite passes across repeated runs. Also corrects the search_by_value comment: jsTotalPrice is null there because the operations API runs on the main thread, where resources.js never registered the resolver — not because of a write-time race. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../computed-indexed-properties.test.mjs | 114 +++++++++++++++++- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/integrationTests/apiTests/computed-indexed-properties.test.mjs b/integrationTests/apiTests/computed-indexed-properties.test.mjs index 82f11024b..b94fd67b0 100644 --- a/integrationTests/apiTests/computed-indexed-properties.test.mjs +++ b/integrationTests/apiTests/computed-indexed-properties.test.mjs @@ -17,6 +17,8 @@ */ import { suite, test, before, after } from 'node:test'; import assert from 'node:assert'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { cpus } from 'node:os'; import request from 'supertest'; import { startHarper, teardownHarper } from '@harperfast/integration-testing'; import { createApiClient } from './utils/client.mjs'; @@ -33,6 +35,104 @@ const RESOURCES_JS = const skipSuite = process.platform === 'win32'; +/** + * Poll until the JS-computed `jsTotalPrice` resolver is live on *every* http + * worker before any real read or write lands. + * + * `jsTotalPrice` is `@computed @indexed` with no `from` expression — its + * resolver is registered at runtime by resources.js (`setComputedAttribute`), + * which sets per-worker in-memory state (Table.ts `userResolvers`), not at + * schema-load time. The `/Product/` route (from the `@export`ed table) starts + * serving as soon as the schema loads, which can be *before* resources.js + * finishes running on a worker. Two failure modes flow from that, and Harper + * runs many http workers (default: cpus-1), so a request can be load-balanced + * (SO_REUSEPORT) to a worker that is still cold: + * - a PUT handled by a cold worker computes the `@indexed` value with a + * missing resolver, freezing `jsTotalPrice` as `undefined` in the index, so + * the `?jsTotalPrice=119` filter misses the record; and + * - a GET `?select(jsTotalPrice)` served by a cold worker recomputes the + * value with a missing resolver and returns null. + * + * The route probe in installAppComponent only proves the table is reachable on + * one worker, not that the resolver is registered everywhere. This gate seeds a + * throwaway record and reads the on-demand computed value in bursts (each burst + * opens fresh connections — the client sends `Connection: close` — so requests + * spread across workers) until several consecutive bursts see zero cold + * responses, i.e. every worker has run resources.js. This is a client-side + * readiness poll, in the spirit of restartHttpWorkers, so the suite stays + * multi-worker (this is a functional test, not a single-thread special case). + * + * The probe record uses taxRate 0 so its computed values are PROBE_COMPUTED (a + * value distinct from the 119 the real assertions filter on), so that even if + * its cleanup delete is missed the leaked probe can never satisfy the + * `?jsTotalPrice=119` / `?totalPrice=119` filter tests. readsPerBurst scales + * with the host core count (workers default to cpus-1) so a straggler worker is + * reliably hit even on high-core CI hosts. + */ +const PROBE_PRICE = 100; +const PROBE_COMPUTED = 100; // price + price * 0 + +async function waitForComputedResolver( + client, + { readsPerBurst = Math.max(60, cpus().length * 8), requiredCleanBursts = 3, timeoutMs = 60000 } = {} +) { + const probeId = '__computed_ready_probe__'; + const cleanup = () => + request(client.restURL) + .delete(`/Product/${probeId}`) + .set(client.headers) + .catch(() => {}); + const deadline = Date.now() + timeoutMs; + try { + // Seed the probe record. A cold/restarting worker can transiently reset the + // connection during warm-up, so retry (treating rejects + non-204 alike) + // rather than failing the suite on a transient error. + let seeded = false; + while (!seeded && Date.now() < deadline) { + try { + const r = await request(client.restURL) + .put(`/Product/${probeId}`) + .set(client.headers) + .send({ id: probeId, price: PROBE_PRICE, taxRate: 0 }); + seeded = r?.status === 204; + } catch { + // connection refused/reset while workers warm up — retry + } + if (!seeded) await sleep(150); + } + if (!seeded) throw new Error('failed to seed the computed-resolver probe record'); + + let cleanBursts = 0; + let lastCold = -1; + while (Date.now() < deadline) { + let cold = 0; + for (let i = 0; i < readsPerBurst; i++) { + // A request to a cold/restarting worker may reject (ECONNRESET/ + // ECONNREFUSED) or return a non-computed value; both count as cold so + // the gate keeps polling instead of failing on a transient error. + try { + const r = await client.reqRest(`/Product/${probeId}?select(id,price,taxRate,jsTotalPrice)`); + if (!(r?.status === 200 && r?.body?.jsTotalPrice === PROBE_COMPUTED)) cold++; + } catch { + cold++; + } + } + lastCold = cold; + if (cold === 0) { + if (++cleanBursts >= requiredCleanBursts) return; + } else { + cleanBursts = 0; + } + await sleep(150); + } + throw new Error( + `jsTotalPrice resolver not live on all workers within ${timeoutMs}ms (last burst cold=${lastCold}/${readsPerBurst})` + ); + } finally { + await cleanup(); + } +} + suite('Computed indexed properties', { skip: skipSuite }, (ctx) => { let client; @@ -46,6 +146,8 @@ suite('Computed indexed properties', { skip: skipSuite }, (ctx) => { probePath: '/Product/', restartTimeoutMs: 120000, }); + + await waitForComputedResolver(client); }); after(async () => { @@ -97,11 +199,13 @@ suite('Computed indexed properties', { skip: skipSuite }, (ctx) => { assert.equal(r.body[0].taxRate, 0.19, r.text); assert.equal(r.body[0].totalPrice, 119, r.text); assert.equal(r.body[0].notIndexedTotalPrice, 119, r.text); - // jsTotalPrice is intentionally not asserted here: search_by_value returns - // the stored indexed value, which can be null if the record was PUT before - // resources.js finished initialising (setComputedAttribute is a runtime - // call, not a schema-time expression). The value is verified via REST GET - // with ?select below, which computes it on-demand. + // jsTotalPrice is intentionally not asserted here: search_by_value is an + // operations-API call served on the main thread, but the jsTotalPrice + // resolver is registered by resources.js (setComputedAttribute) only on the + // http workers. get_attributes recomputes computed fields on the main + // thread, where the resolver is absent, so it resolves to null. The value + // is verified via REST GET with ?select below (served by an http worker, + // which has the resolver). }); test('REST GET by id returns raw fields', async () => {