@effectstream/runtime 0.100.21 → 0.100.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -12
- package/src/api/http-server.ts +9 -0
- package/src/api/stream-status.ts +35 -0
- package/src/finalized-stream.ts +73 -0
- package/src/main.ts +11 -10
- package/test/reproduction/coalesce-memory.test.ts +181 -0
- package/test/reproduction/harness.ts +17 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/runtime",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.23",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": "./src/mod.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@effectstream/concise": "0.100.
|
|
17
|
-
"@effectstream/config": "0.100.
|
|
18
|
-
"@effectstream/coroutine": "0.100.
|
|
19
|
-
"@effectstream/crypto": "0.100.
|
|
20
|
-
"@effectstream/db": "0.100.
|
|
21
|
-
"@effectstream/event-client": "0.100.
|
|
22
|
-
"@effectstream/event-server": "0.100.
|
|
23
|
-
"@effectstream/log": "0.100.
|
|
24
|
-
"@effectstream/sm": "0.100.
|
|
25
|
-
"@effectstream/sync": "0.100.
|
|
26
|
-
"@effectstream/utils": "0.100.
|
|
16
|
+
"@effectstream/concise": "0.100.23",
|
|
17
|
+
"@effectstream/config": "0.100.23",
|
|
18
|
+
"@effectstream/coroutine": "0.100.23",
|
|
19
|
+
"@effectstream/crypto": "0.100.23",
|
|
20
|
+
"@effectstream/db": "0.100.23",
|
|
21
|
+
"@effectstream/event-client": "0.100.23",
|
|
22
|
+
"@effectstream/event-server": "0.100.23",
|
|
23
|
+
"@effectstream/log": "0.100.23",
|
|
24
|
+
"@effectstream/sm": "0.100.23",
|
|
25
|
+
"@effectstream/sync": "0.100.23",
|
|
26
|
+
"@effectstream/utils": "0.100.23",
|
|
27
27
|
"@fastify/cors": "^11.0.1",
|
|
28
28
|
"@metamask/json-rpc-engine": "^10.0.3",
|
|
29
29
|
"pg": "8.21.0",
|
package/src/api/http-server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
|
2
2
|
import { evmRpcEngine } from "./rpc-evm/eip1193.ts";
|
|
3
3
|
import { appliedBlockStatus } from "./apply-status.ts";
|
|
4
|
+
import { finalizedStreamStatus } from "./stream-status.ts";
|
|
4
5
|
import type { Pool } from "pg";
|
|
5
6
|
import cors from "@fastify/cors";
|
|
6
7
|
import { run, until } from "effection";
|
|
@@ -509,6 +510,14 @@ export const startHttpServer = function* (
|
|
|
509
510
|
mergeWaiting: p.mergeWaitingForPage,
|
|
510
511
|
mergeDemandRoot: p.mergeDemandRoot ?? null,
|
|
511
512
|
})),
|
|
513
|
+
// Finalized-block stream backlog: blocks the merge has produced but the
|
|
514
|
+
// apply loop hasn't drained. `inFlight` is the otherwise-unobservable
|
|
515
|
+
// subscriber-queue depth, bounded by backpressure (sync/CLAUDE.md Finding #1).
|
|
516
|
+
// `coalesced` = empty blocks folded away during catch-up.
|
|
517
|
+
finalizedStream: {
|
|
518
|
+
inFlight: finalizedStreamStatus.produced - finalizedStreamStatus.consumed,
|
|
519
|
+
coalesced: finalizedStreamStatus.coalesced,
|
|
520
|
+
},
|
|
512
521
|
// Apply-stage lag: how old the last-applied block is. Unlike `buf` (fetch
|
|
513
522
|
// backlog) this stays high when the node is write/apply-bound.
|
|
514
523
|
applied: {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finalized-block stream backlog for /debug/metrics.
|
|
3
|
+
*
|
|
4
|
+
* `finalized-stream.ts` bumps `produced` when the merge sends a block and `consumed`
|
|
5
|
+
* when the apply loop drains one; their difference is the live subscriber-queue
|
|
6
|
+
* depth that drives backpressure and is surfaced as `inFlight` on /debug/metrics
|
|
7
|
+
* (the one structure flagged unbounded in sync/CLAUDE.md Finding #1). `coalesced`
|
|
8
|
+
* counts empty blocks folded away, so a catch-up test can prove coalescing fired.
|
|
9
|
+
*
|
|
10
|
+
* Process-global: one node runs per process, so a module-level singleton avoids
|
|
11
|
+
* threading a ref through startMerge and the coalescer.
|
|
12
|
+
*/
|
|
13
|
+
export type FinalizedStreamStatus = {
|
|
14
|
+
produced: number;
|
|
15
|
+
consumed: number;
|
|
16
|
+
coalesced: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const finalizedStreamStatus: FinalizedStreamStatus = {
|
|
20
|
+
produced: 0,
|
|
21
|
+
consumed: 0,
|
|
22
|
+
coalesced: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function recordProduced(): void {
|
|
26
|
+
finalizedStreamStatus.produced++;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function recordConsumed(n: number): void {
|
|
30
|
+
finalizedStreamStatus.consumed += n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recordCoalesced(n: number): void {
|
|
34
|
+
finalizedStreamStatus.coalesced += n;
|
|
35
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded hand-off queue between the merge (producer) and the apply loop (consumer).
|
|
3
|
+
*
|
|
4
|
+
* The merge sends finalized blocks into an effection channel; the apply loop drains
|
|
5
|
+
* them. effection channel subscribers buffer unbounded, so during deep catch-up the
|
|
6
|
+
* merge (no I/O) outpaces the apply loop and the queue grows toward the whole backlog
|
|
7
|
+
* → OOM (sync/CLAUDE.md Finding #1) — especially with empty-block coalescing removing
|
|
8
|
+
* the per-block apply brake.
|
|
9
|
+
*
|
|
10
|
+
* This wraps the channel with backpressure: the producer blocks once `cap` produced
|
|
11
|
+
* blocks are still unconsumed, and the consumer wakes it as it drains. The cap only
|
|
12
|
+
* ever throttles the producer (the consumer always keeps pulling), so it can't
|
|
13
|
+
* deadlock. `inFlight = produced − consumed` is the live queue depth, surfaced on
|
|
14
|
+
* /debug/metrics.
|
|
15
|
+
*/
|
|
16
|
+
import { type Channel, createChannel, type Operation } from "effection";
|
|
17
|
+
import { conditionVariable } from "@effectstream/utils";
|
|
18
|
+
import type { ChainBlock } from "@effectstream/sync";
|
|
19
|
+
import {
|
|
20
|
+
finalizedStreamStatus,
|
|
21
|
+
recordConsumed,
|
|
22
|
+
recordProduced,
|
|
23
|
+
} from "./api/stream-status.ts";
|
|
24
|
+
import type { FinalizedBlockSubscription } from "./coalesce.ts";
|
|
25
|
+
|
|
26
|
+
export type BoundedFinalizedStream = {
|
|
27
|
+
/** Send side — hand to `startMerge`. `send` blocks the producer at the cap. */
|
|
28
|
+
stream: Channel<ChainBlock, void>;
|
|
29
|
+
/** Read side — hand to the coalescer / apply loop. Counts drains and wakes the producer. */
|
|
30
|
+
subscription: FinalizedBlockSubscription;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function* createBoundedFinalizedStream(
|
|
34
|
+
cap: number,
|
|
35
|
+
): Operation<BoundedFinalizedStream> {
|
|
36
|
+
const channel = createChannel<ChainBlock>();
|
|
37
|
+
// Subscribe BEFORE the caller spawns the merge: effection channels drop sends
|
|
38
|
+
// with no subscriber, so a fast restart could emit blocks before we subscribe
|
|
39
|
+
// and stall at the pre-restart height.
|
|
40
|
+
const subscription = yield* channel;
|
|
41
|
+
|
|
42
|
+
const drained = conditionVariable<void>();
|
|
43
|
+
const inFlight = () =>
|
|
44
|
+
finalizedStreamStatus.produced - finalizedStreamStatus.consumed;
|
|
45
|
+
|
|
46
|
+
// `startMerge` only uses `.send`; gate it. check-then-wait is atomic under
|
|
47
|
+
// effection's cooperative scheduling, so there's no lost wakeup.
|
|
48
|
+
const stream: Channel<ChainBlock, void> = {
|
|
49
|
+
...channel,
|
|
50
|
+
*send(block: ChainBlock) {
|
|
51
|
+
while (inFlight() >= cap) {
|
|
52
|
+
yield* drained.wait();
|
|
53
|
+
}
|
|
54
|
+
recordProduced();
|
|
55
|
+
yield* channel.send(block);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Count consumed at the pull point (exact channel depth, decoupled from the
|
|
60
|
+
// coalescer's run length) and wake the merge once the queue drops below cap.
|
|
61
|
+
const countedSubscription: FinalizedBlockSubscription = {
|
|
62
|
+
*next() {
|
|
63
|
+
const result = yield* subscription.next();
|
|
64
|
+
if (!result.done) {
|
|
65
|
+
recordConsumed(1);
|
|
66
|
+
if (inFlight() < cap) drained.wake();
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return { stream, subscription: countedSubscription };
|
|
73
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
import { startMerge, startSync } from "@effectstream/sync";
|
|
24
24
|
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
25
25
|
import {
|
|
26
|
-
createChannel,
|
|
27
26
|
type Operation,
|
|
28
27
|
sleep,
|
|
29
28
|
spawn,
|
|
@@ -37,6 +36,8 @@ import {
|
|
|
37
36
|
import { createEmptyBlockCoalescer } from "./coalesce.ts";
|
|
38
37
|
import { startHttpServer } from "./api/http-server.ts";
|
|
39
38
|
import { recordAppliedBlock } from "./api/apply-status.ts";
|
|
39
|
+
import { recordCoalesced } from "./api/stream-status.ts";
|
|
40
|
+
import { createBoundedFinalizedStream } from "./finalized-stream.ts";
|
|
40
41
|
import type { StartConfig } from "./types.ts";
|
|
41
42
|
import type { Client } from "pg";
|
|
42
43
|
import type { EffectstreamBlockHash } from "@effectstream/utils";
|
|
@@ -102,16 +103,13 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
102
103
|
(syncInfo[0]?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS;
|
|
103
104
|
const lagThresholdMs = clockBlockTimeMS != null ? clockBlockTimeMS * 10 : undefined;
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
// Bounded hand-off queue between the merge and the apply loop. Backpressure caps
|
|
107
|
+
// the in-memory queue so deep catch-up can't grow it toward the whole backlog
|
|
108
|
+
// (see finalized-stream.ts / sync/CLAUDE.md Finding #1).
|
|
109
|
+
const { stream: finalizedStream, subscription: finalizedBlocks } =
|
|
110
|
+
yield* createBoundedFinalizedStream(ENV.EFFECTSTREAM_FINALIZED_STREAM_CAP);
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
// subscriber, so a fast restart could emit blocks before we subscribe and
|
|
109
|
-
// stall at the pre-restart height.
|
|
110
|
-
const finalizedBlocks = yield* finalizedBlockStream;
|
|
111
|
-
// NOTE: this subscriber's queue is unbounded — during deep catch-up the merge
|
|
112
|
-
// can outpace block processing and grow it (Fix C backpressure, sync/CLAUDE.md).
|
|
113
|
-
|
|
114
|
-
yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
|
|
112
|
+
yield* spawn(() => startMerge(syncProtocols, finalizedStream));
|
|
115
113
|
|
|
116
114
|
const heartbeatIntervalMs = 60_000;
|
|
117
115
|
yield* spawn(function* () {
|
|
@@ -161,6 +159,9 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
161
159
|
lagThresholdMs,
|
|
162
160
|
getPreviousBlockHash: () => blockHash,
|
|
163
161
|
onFlush: (endpoint, length) => {
|
|
162
|
+
// `consumed` is counted at the subscription pull point (above), not here, so
|
|
163
|
+
// it reflects the channel depth regardless of how many blocks a run folds.
|
|
164
|
+
recordCoalesced(length);
|
|
164
165
|
recordAppliedBlock(endpoint);
|
|
165
166
|
emitLatestBlocks(
|
|
166
167
|
endpoint.blockNumber,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory / queue-depth characterization for empty-block coalescing during a deep
|
|
3
|
+
* catch-up (sync/CLAUDE.md Finding #1 + Fix E).
|
|
4
|
+
*
|
|
5
|
+
* Coalescing is a consumer-side speedup: folding empty catch-up blocks removes the
|
|
6
|
+
* per-block STF + DB-commit bottleneck that otherwise acts as accidental
|
|
7
|
+
* backpressure. The worry is that, with that brake gone, the fetchers race to tip
|
|
8
|
+
* and either (a) balloon the uncapped `finalizedBlockStream` subscriber queue or
|
|
9
|
+
* (b) keep a parallel chain in the cap-lifted merge-demand exemption long enough to
|
|
10
|
+
* grow its Deque toward the whole backlog.
|
|
11
|
+
*
|
|
12
|
+
* This test drives a deep, all-empty catch-up with coalescing ON and asserts hard
|
|
13
|
+
* ceilings on the stream backlog (`inFlight`) and per-protocol buffers, plus a soft
|
|
14
|
+
* memory guard and a liveness check. Green → coalescing keeps everything bounded
|
|
15
|
+
* (regression guard). Red on the backlog/buffer bound → reproduces the leak, and the
|
|
16
|
+
* captured peak series localizes it.
|
|
17
|
+
*
|
|
18
|
+
* Postgres-only (chosen backend): higher-fidelity apply throughput than PGLite.
|
|
19
|
+
* Skips when no Postgres binary is on PATH.
|
|
20
|
+
*/
|
|
21
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
22
|
+
import {
|
|
23
|
+
ConfigBuilder,
|
|
24
|
+
ConfigNetworkType,
|
|
25
|
+
ConfigSyncProtocolType,
|
|
26
|
+
} from "@effectstream/config";
|
|
27
|
+
import { TestChainControl } from "@effectstream/sync";
|
|
28
|
+
import {
|
|
29
|
+
bufferSize,
|
|
30
|
+
type Harness,
|
|
31
|
+
latestFinalizedHeight,
|
|
32
|
+
postgresAvailable,
|
|
33
|
+
type RunningNode,
|
|
34
|
+
setupHarness,
|
|
35
|
+
sleep,
|
|
36
|
+
TEST_PRIMITIVE_TYPE,
|
|
37
|
+
} from "./harness.ts";
|
|
38
|
+
|
|
39
|
+
const RUN = postgresAvailable();
|
|
40
|
+
|
|
41
|
+
const START_TIME = 1_700_000_000_000; // ~Nov 2023: every catch-up block is "old"
|
|
42
|
+
const MAIN_BLOCK_MS = 1000;
|
|
43
|
+
const PAR_BLOCK_MS = 100; // 10 parallel blocks per root slot
|
|
44
|
+
const STEP_SIZE = 1000;
|
|
45
|
+
const CAP = 3000;
|
|
46
|
+
|
|
47
|
+
// A deep backlog: ~15k root slots the one-block-per-txn merge could never drain to
|
|
48
|
+
// in time, so without coalescing the node would crawl — and any unbounded structure
|
|
49
|
+
// would grow toward the whole backlog. Coalescing must fold the empties and keep
|
|
50
|
+
// memory flat.
|
|
51
|
+
const MAIN_TIP = 15_000;
|
|
52
|
+
const PAR_TIP = MAIN_TIP * (MAIN_BLOCK_MS / PAR_BLOCK_MS); // cover the same wall range
|
|
53
|
+
|
|
54
|
+
// Buffer settles in [~CAP, CAP + stepSize]; allow one overshoot chunk + slack.
|
|
55
|
+
const BUFFER_BOUND = CAP + STEP_SIZE + 200;
|
|
56
|
+
// The stream backlog must stay far below the whole-backlog (~MAIN_TIP) failure mode.
|
|
57
|
+
// A healthy fast consumer keeps this in the low hundreds; we allow generous slack.
|
|
58
|
+
const QUEUE_BOUND = 5_000;
|
|
59
|
+
// Liveness: the node must catch up essentially to the tip (no deadlock / no crawl).
|
|
60
|
+
const LIVE_TARGET = Math.floor(MAIN_TIP * 0.9);
|
|
61
|
+
|
|
62
|
+
const MAIN = "memMainClock";
|
|
63
|
+
const PAR = "memParallelP";
|
|
64
|
+
|
|
65
|
+
function buildConfig() {
|
|
66
|
+
return new ConfigBuilder()
|
|
67
|
+
.setNamespace((b) => b.setSecurityNamespace("sync-coalesce-mem"))
|
|
68
|
+
.buildNetworks((b) =>
|
|
69
|
+
b
|
|
70
|
+
.addNetwork({
|
|
71
|
+
name: "clock",
|
|
72
|
+
type: ConfigNetworkType.TEST,
|
|
73
|
+
startTime: START_TIME,
|
|
74
|
+
blockTimeMS: MAIN_BLOCK_MS,
|
|
75
|
+
})
|
|
76
|
+
.addNetwork({
|
|
77
|
+
name: "chainP",
|
|
78
|
+
type: ConfigNetworkType.TEST,
|
|
79
|
+
startTime: START_TIME,
|
|
80
|
+
blockTimeMS: PAR_BLOCK_MS,
|
|
81
|
+
})
|
|
82
|
+
)
|
|
83
|
+
.buildDeployments((b) => b)
|
|
84
|
+
.buildSyncProtocols((b) =>
|
|
85
|
+
b
|
|
86
|
+
.addMain(
|
|
87
|
+
(n) => n.clock,
|
|
88
|
+
() => ({
|
|
89
|
+
name: MAIN,
|
|
90
|
+
type: ConfigSyncProtocolType.TEST_MAIN,
|
|
91
|
+
startBlockHeight: 1,
|
|
92
|
+
pollingInterval: 30,
|
|
93
|
+
stepSize: STEP_SIZE,
|
|
94
|
+
maxBufferedPages: CAP,
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
.addParallel(
|
|
98
|
+
(n) => (n as any).chainP,
|
|
99
|
+
() => ({
|
|
100
|
+
name: PAR,
|
|
101
|
+
type: ConfigSyncProtocolType.TEST_PARALLEL,
|
|
102
|
+
startBlockHeight: 1,
|
|
103
|
+
pollingInterval: 30,
|
|
104
|
+
stepSize: STEP_SIZE,
|
|
105
|
+
delayMs: 0,
|
|
106
|
+
events: [], // no primitives → every block is coalescable
|
|
107
|
+
maxBufferedPages: CAP,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
.buildPrimitives((b) =>
|
|
112
|
+
b.addPrimitive(
|
|
113
|
+
(sp) => (sp as any)[PAR],
|
|
114
|
+
() => ({ name: "noEvtMem", type: TEST_PRIMITIVE_TYPE, startBlockHeight: 1 }),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
.build();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let h: Harness;
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
if (!RUN) return;
|
|
123
|
+
h = await setupHarness({ backend: "postgres" });
|
|
124
|
+
});
|
|
125
|
+
afterEach(async () => {
|
|
126
|
+
if (!RUN) return;
|
|
127
|
+
await h?.teardown();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("coalescing keeps stream backlog & buffers bounded during a deep empty catch-up", async () => {
|
|
131
|
+
if (!RUN) {
|
|
132
|
+
console.warn("coalesce-memory: skipped (no Postgres binary on PATH)");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
TestChainControl.setTip(MAIN, MAIN_TIP);
|
|
137
|
+
TestChainControl.setTip(PAR, PAR_TIP);
|
|
138
|
+
|
|
139
|
+
const node: RunningNode = await h.runNode({
|
|
140
|
+
config: buildConfig(),
|
|
141
|
+
apiPort: 19181,
|
|
142
|
+
coalesce: true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
let peakMain = 0;
|
|
146
|
+
let peakPar = 0;
|
|
147
|
+
let peakInFlight = 0;
|
|
148
|
+
let lastCoalesced = 0;
|
|
149
|
+
let lastApplied = 0;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const startMs = Date.now();
|
|
153
|
+
const deadline = startMs + 110_000;
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
peakMain = Math.max(peakMain, bufferSize(node, MAIN));
|
|
156
|
+
peakPar = Math.max(peakPar, bufferSize(node, PAR));
|
|
157
|
+
peakInFlight = Math.max(peakInFlight, node.inFlight);
|
|
158
|
+
lastCoalesced = node.coalescedBlocks;
|
|
159
|
+
lastApplied = (await latestFinalizedHeight(h.pool)) ?? lastApplied;
|
|
160
|
+
if (lastApplied >= LIVE_TARGET) break; // caught up — bounded steady state captured
|
|
161
|
+
await sleep(50);
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
console.log(
|
|
165
|
+
`coalesce-memory: peakInFlight=${peakInFlight} mainBuf=${peakMain} ` +
|
|
166
|
+
`parBuf=${peakPar} coalesced=${lastCoalesced} applied=${lastApplied}`,
|
|
167
|
+
);
|
|
168
|
+
await node.stop();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 1. Coalescing actually fired (else this is a no-op test).
|
|
172
|
+
expect(lastCoalesced).toBeGreaterThan(1000);
|
|
173
|
+
// 2. Stream backlog stayed bounded — the merge did NOT outrun the consumer
|
|
174
|
+
// toward the whole backlog (the uncapped subscriber queue, Finding #1). This
|
|
175
|
+
// bounded queue is what keeps memory flat; no separate RSS check is needed.
|
|
176
|
+
expect(peakInFlight).toBeLessThanOrEqual(QUEUE_BOUND);
|
|
177
|
+
// 3. Per-protocol buffers stayed at the cap, even under coalescing's fast consumer.
|
|
178
|
+
expect(Math.max(peakMain, peakPar)).toBeLessThanOrEqual(BUFFER_BOUND);
|
|
179
|
+
// 4. Liveness: the node caught up to the tip (no deadlock / no crawl).
|
|
180
|
+
expect(lastApplied).toBeGreaterThanOrEqual(LIVE_TARGET);
|
|
181
|
+
}, 120_000);
|
|
@@ -32,6 +32,8 @@ import { toSyncProtocolWithNetwork } from "@effectstream/config";
|
|
|
32
32
|
export type RunNodeOpts = {
|
|
33
33
|
config: any;
|
|
34
34
|
apiPort: number;
|
|
35
|
+
/** Enable/disable empty-block coalescing for this long-lived node. */
|
|
36
|
+
coalesce?: boolean;
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
export type RunningNode = {
|
|
@@ -40,6 +42,10 @@ export type RunningNode = {
|
|
|
40
42
|
bufferSizes: Record<string, number>;
|
|
41
43
|
pauses: Record<string, number>;
|
|
42
44
|
ownBlocks: Record<string, number | null>;
|
|
45
|
+
/** finalizedBlockStream backlog (produced − consumed); 0 until first scrape. */
|
|
46
|
+
inFlight: number;
|
|
47
|
+
/** Empty blocks folded away by the coalescer so far. */
|
|
48
|
+
coalescedBlocks: number;
|
|
43
49
|
stop: () => Promise<void>;
|
|
44
50
|
syncProtocols: () => any[];
|
|
45
51
|
};
|
|
@@ -230,8 +236,6 @@ function ensureCluster(): Promise<Cluster> {
|
|
|
230
236
|
const dataDir = mkdtempSync(join(tmpdir(), "repro-pg-"));
|
|
231
237
|
const port = await freePort();
|
|
232
238
|
|
|
233
|
-
writeFileSync(join(dataDir, "repro-owner.pid"), String(process.pid));
|
|
234
|
-
|
|
235
239
|
execFileSync(join(bin, "initdb"), [
|
|
236
240
|
"-D", dataDir,
|
|
237
241
|
"-U", "postgres",
|
|
@@ -240,6 +244,10 @@ function ensureCluster(): Promise<Cluster> {
|
|
|
240
244
|
"--encoding=UTF8",
|
|
241
245
|
"--no-instructions",
|
|
242
246
|
], { env, stdio: "ignore" });
|
|
247
|
+
|
|
248
|
+
// Write our owner marker AFTER initdb — it refuses a non-empty data dir, so
|
|
249
|
+
// this must not exist beforehand. Read later by cleanupOrphanedClusters.
|
|
250
|
+
writeFileSync(join(dataDir, "repro-owner.pid"), String(process.pid));
|
|
243
251
|
appendFileSync(join(dataDir, "postgresql.conf"), `\nport = ${port}\n`);
|
|
244
252
|
execFileSync(join(bin, "pg_ctl"), [
|
|
245
253
|
"-D", dataDir,
|
|
@@ -384,6 +392,7 @@ function makeRunNode(
|
|
|
384
392
|
target: 999999,
|
|
385
393
|
apiPort: opts.apiPort,
|
|
386
394
|
config: opts.config,
|
|
395
|
+
coalesce: opts.coalesce,
|
|
387
396
|
};
|
|
388
397
|
|
|
389
398
|
const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
|
|
@@ -401,6 +410,8 @@ function makeRunNode(
|
|
|
401
410
|
bufferSizes: {},
|
|
402
411
|
pauses: {},
|
|
403
412
|
ownBlocks: {},
|
|
413
|
+
inFlight: 0,
|
|
414
|
+
coalescedBlocks: 0,
|
|
404
415
|
syncProtocols() {
|
|
405
416
|
return Object.entries(this.bufferSizes).map(([name, buf]) => ({
|
|
406
417
|
name,
|
|
@@ -433,6 +444,10 @@ function makeRunNode(
|
|
|
433
444
|
node.ownBlocks[p.name] = p.ownBlockNumber;
|
|
434
445
|
}
|
|
435
446
|
}
|
|
447
|
+
if (data?.finalizedStream) {
|
|
448
|
+
node.inFlight = data.finalizedStream.inFlight ?? 0;
|
|
449
|
+
node.coalescedBlocks = data.finalizedStream.coalesced ?? 0;
|
|
450
|
+
}
|
|
436
451
|
}
|
|
437
452
|
} catch {
|
|
438
453
|
// ignore
|