pi-crew 0.11.52 → 0.11.53
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/CHANGELOG.md +34 -7
- package/dist/index.mjs +479 -479
- package/package.json +1 -1
- package/src/state/event-log/event-log.ts +88 -45
package/package.json
CHANGED
|
@@ -1210,6 +1210,8 @@ interface BufferedAppend {
|
|
|
1210
1210
|
}
|
|
1211
1211
|
const bufferedQueues = new Map<string, BufferedAppend[]>();
|
|
1212
1212
|
const bufferedTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1213
|
+
/** Per-path in-flight flush (started, not yet settled) — see flushOneEventLogBuffer. */
|
|
1214
|
+
const inflightBufferFlushes = new Map<string, Promise<void>>();
|
|
1213
1215
|
const DEFAULT_BUFFER_MS = 20;
|
|
1214
1216
|
|
|
1215
1217
|
export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent, bufferMs = DEFAULT_BUFFER_MS): Promise<TeamEvent> {
|
|
@@ -1253,61 +1255,95 @@ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
|
|
|
1253
1255
|
const queue = bufferedQueues.get(eventsPath);
|
|
1254
1256
|
bufferedQueues.delete(eventsPath);
|
|
1255
1257
|
const timer = bufferedTimers.get(eventsPath);
|
|
1256
|
-
//
|
|
1257
|
-
|
|
1258
|
-
|
|
1258
|
+
// PERF-CORRECTNESS (2026-10-31, macos CI run 41147573288): track the
|
|
1259
|
+
// in-flight flush so flushEventLogBuffer()/drainEventLogWriters() can AWAIT
|
|
1260
|
+
// it. Previously those helpers were no-ops for a path whose flush had just
|
|
1261
|
+
// STARTED (the queue is deleted from the map above BEFORE the multi-turn
|
|
1262
|
+
// async lock+append completes), so `await flushEventLogBuffer()` could
|
|
1263
|
+
// return while the appendFile was still in flight — the interrupt-guard
|
|
1264
|
+
// detached test read the log before the event landed (macos runner fs).
|
|
1265
|
+
// Same window on the exit path: flushBufferedQueuesSync sees an empty map
|
|
1266
|
+
// and cannot recover an in-flight batch.
|
|
1267
|
+
const doFlush = (async (): Promise<void> => {
|
|
1268
|
+
// Timer is cleared in the finally block to ensure cleanup happens even on error
|
|
1269
|
+
try {
|
|
1270
|
+
if (!queue || queue.length === 0) return;
|
|
1259
1271
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
new Error(
|
|
1275
|
-
`Buffer overflow #${overflowCounter}: Dropped ${dropped.length} events: first seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}, last seq=${lastDroppedMeta?.seq} type=${dropped[dropped.length - 1]?.event.type}`,
|
|
1276
|
-
),
|
|
1277
|
-
`${eventsPath}: ${queue.length + dropped.length} entries > 1000 cap`,
|
|
1278
|
-
);
|
|
1279
|
-
for (const item of dropped) {
|
|
1280
|
-
item.reject(
|
|
1272
|
+
// FIX (Round 14, H3): When truncating the queue, explicitly reject the
|
|
1273
|
+
// dropped entries' promises. Previously `queue.splice()` silently
|
|
1274
|
+
// discarded the oldest items, and their associated Promises were never
|
|
1275
|
+
// resolved or rejected — causing callers to await forever and leaking
|
|
1276
|
+
// memory. We now reject with a clear error so callers can fall back.
|
|
1277
|
+
if (queue.length > 1000) {
|
|
1278
|
+
const dropped = queue.splice(0, queue.length - 500);
|
|
1279
|
+
overflowCounter++;
|
|
1280
|
+
// FIX: Include first/last dropped event type and sequence number in error
|
|
1281
|
+
// message to make debugging easier when events are dropped.
|
|
1282
|
+
const firstDroppedMeta = dropped[0]?.event.metadata;
|
|
1283
|
+
const lastDroppedMeta = dropped[dropped.length - 1]?.event.metadata;
|
|
1284
|
+
logInternalError(
|
|
1285
|
+
"event-log.buffer-overflow",
|
|
1281
1286
|
new Error(
|
|
1282
|
-
`
|
|
1287
|
+
`Buffer overflow #${overflowCounter}: Dropped ${dropped.length} events: first seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}, last seq=${lastDroppedMeta?.seq} type=${dropped[dropped.length - 1]?.event.type}`,
|
|
1283
1288
|
),
|
|
1289
|
+
`${eventsPath}: ${queue.length + dropped.length} entries > 1000 cap`,
|
|
1284
1290
|
);
|
|
1291
|
+
for (const item of dropped) {
|
|
1292
|
+
item.reject(
|
|
1293
|
+
new Error(
|
|
1294
|
+
`Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded; first dropped seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}`,
|
|
1295
|
+
),
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1285
1298
|
}
|
|
1286
|
-
}
|
|
1287
1299
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1300
|
+
// FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
|
|
1301
|
+
// blocking the event loop. The sync lock uses sleepSync which blocks for
|
|
1302
|
+
// up to 5s and prevents AbortSignal handlers from firing.
|
|
1303
|
+
// FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
|
|
1304
|
+
// the whole queue. Previously each event triggered its own fsyncSync,
|
|
1305
|
+
// turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
|
|
1306
|
+
// 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
|
|
1307
|
+
await withEventLogLockAsync(eventsPath, async () => {
|
|
1308
|
+
// R7-2 (DR-04): `.alock` HELD here (async family) — the batch's inline
|
|
1309
|
+
// compaction must not re-acquire it.
|
|
1310
|
+
await appendEventBatchInsideLock(eventsPath, queue, { holdsAppendLock: true });
|
|
1311
|
+
});
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
// Lock acquire failed — fail every queued item so callers can fall back.
|
|
1314
|
+
if (queue) for (const item of queue) item.reject(error);
|
|
1315
|
+
} finally {
|
|
1316
|
+
bufferedTimers.delete(eventsPath);
|
|
1317
|
+
}
|
|
1318
|
+
})();
|
|
1319
|
+
// Chain per-path: a second flush must wait for the in-flight one (keeps the
|
|
1320
|
+
// tracker single-entry and resolves followers with the leader's outcome).
|
|
1321
|
+
const prevInflight = inflightBufferFlushes.get(eventsPath) ?? Promise.resolve();
|
|
1322
|
+
const tracked = prevInflight.then(
|
|
1323
|
+
() => doFlush,
|
|
1324
|
+
() => doFlush,
|
|
1325
|
+
);
|
|
1326
|
+
inflightBufferFlushes.set(
|
|
1327
|
+
eventsPath,
|
|
1328
|
+
tracked.finally(() => {
|
|
1329
|
+
if (inflightBufferFlushes.get(eventsPath) === tracked) inflightBufferFlushes.delete(eventsPath);
|
|
1330
|
+
}),
|
|
1331
|
+
);
|
|
1332
|
+
await tracked;
|
|
1306
1333
|
}
|
|
1307
1334
|
|
|
1308
1335
|
/** Asynchronously flush every queued buffered event across all paths. */
|
|
1309
1336
|
export async function flushEventLogBuffer(): Promise<void> {
|
|
1310
|
-
for (const eventsPath of [...bufferedQueues.keys()])
|
|
1337
|
+
for (const eventsPath of [...bufferedQueues.keys()]) {
|
|
1338
|
+
// PERF-CORRECTNESS (2026-10-31): await any IN-FLIGHT flush for this path
|
|
1339
|
+
// first — the timer-led flush may hold events that are no longer in
|
|
1340
|
+
// bufferedQueues (removed at flush start). Without this, a caller that
|
|
1341
|
+
// awaits flushEventLogBuffer() during an in-flight flush observes a log
|
|
1342
|
+
// that does not yet contain those events (deterministic-await guarantee).
|
|
1343
|
+
const inflight = inflightBufferFlushes.get(eventsPath);
|
|
1344
|
+
if (inflight) await inflight.catch(() => undefined);
|
|
1345
|
+
await flushOneEventLogBuffer(eventsPath);
|
|
1346
|
+
}
|
|
1311
1347
|
}
|
|
1312
1348
|
|
|
1313
1349
|
/**
|
|
@@ -1402,6 +1438,12 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
|
|
|
1402
1438
|
export async function drainEventLogWriters(eventsPath?: string): Promise<void> {
|
|
1403
1439
|
if (eventsPath !== undefined) {
|
|
1404
1440
|
const tail = asyncQueues.get(eventsPath) ?? Promise.resolve();
|
|
1441
|
+
// PERF-CORRECTNESS (2026-10-31): the buffered flush may be IN FLIGHT with
|
|
1442
|
+
// the queue already removed from the map — await the tracker, then flush
|
|
1443
|
+
// whatever landed in the queue since (deterministic drain for tests and
|
|
1444
|
+
// teardown paths; see flushOneEventLogBuffer).
|
|
1445
|
+
const inflight = inflightBufferFlushes.get(eventsPath);
|
|
1446
|
+
if (inflight) await inflight.catch(() => undefined);
|
|
1405
1447
|
await Promise.allSettled([tail]);
|
|
1406
1448
|
try {
|
|
1407
1449
|
await flushOneEventLogBuffer(eventsPath);
|
|
@@ -1410,6 +1452,7 @@ export async function drainEventLogWriters(eventsPath?: string): Promise<void> {
|
|
|
1410
1452
|
}
|
|
1411
1453
|
return;
|
|
1412
1454
|
}
|
|
1455
|
+
await flushEventLogBuffer().catch(() => undefined);
|
|
1413
1456
|
await Promise.allSettled([...asyncQueues.values()]);
|
|
1414
1457
|
try {
|
|
1415
1458
|
await flushEventLogBuffer();
|