viberag 0.7.0 → 0.8.0

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.
@@ -0,0 +1,683 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {execFile as execFileCb} from 'node:child_process';
6
+ import {promisify} from 'node:util';
7
+ import {fileURLToPath} from 'node:url';
8
+
9
+ const execFile = promisify(execFileCb);
10
+
11
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
12
+ const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..');
13
+ const DAEMON_ENTRY = path.join(REPO_ROOT, 'dist/daemon/index.js');
14
+
15
+ const DURATION_SECONDS = Number(
16
+ process.env['VIBERAG_WATCH_STRESS_SECONDS'] ?? '60',
17
+ );
18
+ const WRITER_COUNT = Number(
19
+ process.env['VIBERAG_WATCH_STRESS_WRITERS'] ?? '48',
20
+ );
21
+ const FILE_COUNT = Number(process.env['VIBERAG_WATCH_STRESS_FILES'] ?? '96');
22
+ const WRITE_INTERVAL_MS = Number(
23
+ process.env['VIBERAG_WATCH_STRESS_WRITE_INTERVAL_MS'] ?? '0',
24
+ );
25
+ const SAMPLE_MS = Number(
26
+ process.env['VIBERAG_WATCH_STRESS_SAMPLE_MS'] ?? '500',
27
+ );
28
+ const CONTENT_KB = Number(
29
+ process.env['VIBERAG_WATCH_STRESS_CONTENT_KB'] ?? '2',
30
+ );
31
+ const COOLDOWN_SECONDS = Number(
32
+ process.env['VIBERAG_WATCH_STRESS_COOLDOWN_SECONDS'] ?? '10',
33
+ );
34
+ const CLEANUP_SETTLE_SECONDS = Number(
35
+ process.env['VIBERAG_WATCH_STRESS_CLEANUP_SETTLE_SECONDS'] ?? '8',
36
+ );
37
+ const MAX_RSS_MB = Number(
38
+ process.env['VIBERAG_WATCH_STRESS_MAX_RSS_MB'] ?? '10240',
39
+ );
40
+ const SOFT_RSS_MB = Number(
41
+ process.env['VIBERAG_WATCH_STRESS_SOFT_RSS_MB'] ??
42
+ Math.floor(MAX_RSS_MB * 0.8),
43
+ );
44
+ const GUARD_KILL = process.env['VIBERAG_WATCH_STRESS_GUARD_KILL'] !== '0';
45
+ const CLEAN_START = process.env['VIBERAG_WATCH_STRESS_CLEAN_START'] !== '0';
46
+ const TARGET_DIR_REL =
47
+ process.env['VIBERAG_WATCH_STRESS_TARGET_DIR'] ??
48
+ 'source/daemon/__watch_stress_probe';
49
+ const TARGET_DIR = path.resolve(REPO_ROOT, TARGET_DIR_REL);
50
+ const OUTPUT_DIR = process.env['VIBERAG_WATCH_STRESS_OUTPUT_DIR']
51
+ ? path.resolve(process.env['VIBERAG_WATCH_STRESS_OUTPUT_DIR'])
52
+ : path.join(REPO_ROOT, 'scripts/memory/out');
53
+
54
+ if (!Number.isFinite(DURATION_SECONDS) || DURATION_SECONDS <= 0) {
55
+ throw new Error('VIBERAG_WATCH_STRESS_SECONDS must be > 0');
56
+ }
57
+ if (!Number.isFinite(WRITER_COUNT) || WRITER_COUNT <= 0) {
58
+ throw new Error('VIBERAG_WATCH_STRESS_WRITERS must be > 0');
59
+ }
60
+ if (!Number.isFinite(FILE_COUNT) || FILE_COUNT <= 0) {
61
+ throw new Error('VIBERAG_WATCH_STRESS_FILES must be > 0');
62
+ }
63
+ if (!Number.isFinite(WRITE_INTERVAL_MS) || WRITE_INTERVAL_MS < 0) {
64
+ throw new Error('VIBERAG_WATCH_STRESS_WRITE_INTERVAL_MS must be >= 0');
65
+ }
66
+ if (!Number.isFinite(SAMPLE_MS) || SAMPLE_MS < 100) {
67
+ throw new Error('VIBERAG_WATCH_STRESS_SAMPLE_MS must be >= 100');
68
+ }
69
+ if (!Number.isFinite(CONTENT_KB) || CONTENT_KB < 0.25) {
70
+ throw new Error('VIBERAG_WATCH_STRESS_CONTENT_KB must be >= 0.25');
71
+ }
72
+ if (!Number.isFinite(COOLDOWN_SECONDS) || COOLDOWN_SECONDS < 0) {
73
+ throw new Error('VIBERAG_WATCH_STRESS_COOLDOWN_SECONDS must be >= 0');
74
+ }
75
+ if (!Number.isFinite(CLEANUP_SETTLE_SECONDS) || CLEANUP_SETTLE_SECONDS < 0) {
76
+ throw new Error('VIBERAG_WATCH_STRESS_CLEANUP_SETTLE_SECONDS must be >= 0');
77
+ }
78
+ if (!Number.isFinite(MAX_RSS_MB) || MAX_RSS_MB <= 0) {
79
+ throw new Error('VIBERAG_WATCH_STRESS_MAX_RSS_MB must be > 0');
80
+ }
81
+ if (
82
+ !Number.isFinite(SOFT_RSS_MB) ||
83
+ SOFT_RSS_MB <= 0 ||
84
+ SOFT_RSS_MB > MAX_RSS_MB
85
+ ) {
86
+ throw new Error(
87
+ 'VIBERAG_WATCH_STRESS_SOFT_RSS_MB must be > 0 and <= VIBERAG_WATCH_STRESS_MAX_RSS_MB',
88
+ );
89
+ }
90
+
91
+ function sleep(ms) {
92
+ return new Promise(resolve => setTimeout(resolve, ms));
93
+ }
94
+
95
+ function nowIso() {
96
+ return new Date().toISOString();
97
+ }
98
+
99
+ function compactError(error) {
100
+ if (error instanceof Error) {
101
+ return `${error.name}: ${error.message}`;
102
+ }
103
+ return String(error);
104
+ }
105
+
106
+ function mbFromKb(value) {
107
+ return Number((value / 1024).toFixed(1));
108
+ }
109
+
110
+ async function withTimeout(promise, timeoutMs, label) {
111
+ const timeout = new Promise((_, reject) => {
112
+ setTimeout(
113
+ () => reject(new Error(`timeout:${label}:${timeoutMs}ms`)),
114
+ timeoutMs,
115
+ );
116
+ });
117
+ return Promise.race([promise, timeout]);
118
+ }
119
+
120
+ async function listDaemonRows() {
121
+ const {stdout} = await execFile('ps', [
122
+ '-axo',
123
+ 'pid=,rss=,vsz=,%cpu=,%mem=,state=,etime=,command=',
124
+ ]);
125
+ const rows = [];
126
+ for (const line of stdout.split('\n')) {
127
+ if (!line.includes(DAEMON_ENTRY)) {
128
+ continue;
129
+ }
130
+ const match = line.match(
131
+ /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([0-9.]+)\s+([0-9.]+)\s+(\S+)\s+(\S+)\s+(.+)$/,
132
+ );
133
+ if (!match) {
134
+ continue;
135
+ }
136
+ rows.push({
137
+ pid: Number(match[1]),
138
+ rssMB: mbFromKb(Number(match[2])),
139
+ vszMB: mbFromKb(Number(match[3])),
140
+ cpuPercent: Number(match[4]),
141
+ memPercent: Number(match[5]),
142
+ state: match[6],
143
+ etime: match[7],
144
+ command: match[8],
145
+ });
146
+ }
147
+ return rows;
148
+ }
149
+
150
+ async function findDaemonProcess() {
151
+ const rows = await listDaemonRows();
152
+ return rows[0] ?? null;
153
+ }
154
+
155
+ async function cleanupStaleRunArtifacts() {
156
+ const {getDaemonLockPath, getDaemonPidPath, getDaemonSocketPath} =
157
+ await import(path.join(REPO_ROOT, 'dist/daemon/lib/constants.js'));
158
+ const stalePaths = [
159
+ getDaemonLockPath(REPO_ROOT),
160
+ getDaemonPidPath(REPO_ROOT),
161
+ getDaemonSocketPath(REPO_ROOT),
162
+ ];
163
+ for (const stalePath of stalePaths) {
164
+ try {
165
+ await fs.rm(stalePath, {force: true, recursive: true});
166
+ } catch {}
167
+ }
168
+ }
169
+
170
+ async function killDaemonProcesses() {
171
+ const initial = await listDaemonRows();
172
+ if (initial.length === 0) {
173
+ await cleanupStaleRunArtifacts();
174
+ return {killed: [], zombieLike: []};
175
+ }
176
+
177
+ const killed = [];
178
+ for (const row of initial) {
179
+ try {
180
+ process.kill(row.pid, 'SIGTERM');
181
+ killed.push({pid: row.pid, signal: 'SIGTERM'});
182
+ } catch {}
183
+ }
184
+ await sleep(500);
185
+
186
+ const afterTerm = await listDaemonRows();
187
+ for (const row of afterTerm) {
188
+ try {
189
+ process.kill(row.pid, 'SIGKILL');
190
+ killed.push({pid: row.pid, signal: 'SIGKILL'});
191
+ } catch {}
192
+ }
193
+ await sleep(500);
194
+
195
+ const stillThere = await listDaemonRows();
196
+ if (stillThere.length === 0) {
197
+ await cleanupStaleRunArtifacts();
198
+ }
199
+ return {killed, zombieLike: stillThere.map(row => row.pid)};
200
+ }
201
+
202
+ function addErrorBucket(state, error) {
203
+ const key = compactError(error).slice(0, 220);
204
+ state.errorBuckets.set(key, (state.errorBuckets.get(key) ?? 0) + 1);
205
+ }
206
+
207
+ function topErrorBuckets(state, limit = 10) {
208
+ return [...state.errorBuckets.entries()]
209
+ .sort((a, b) => b[1] - a[1])
210
+ .slice(0, limit)
211
+ .map(([error, count]) => ({error, count}));
212
+ }
213
+
214
+ function summarizeSamples(samples) {
215
+ const observed = samples
216
+ .map(sample => sample.observedRssMB)
217
+ .filter(Number.isFinite);
218
+ const statusRss = samples
219
+ .map(sample => sample.rssMBStatus)
220
+ .filter(Number.isFinite);
221
+ const psRss = samples.map(sample => sample.rssMBPs).filter(Number.isFinite);
222
+ const pendingChanges = samples
223
+ .map(sample => sample.watcherPendingChanges)
224
+ .filter(Number.isFinite);
225
+
226
+ if (observed.length === 0) {
227
+ return null;
228
+ }
229
+
230
+ return {
231
+ observedStartMB: observed[0],
232
+ observedEndMB: observed.at(-1),
233
+ observedPeakMB: Math.max(...observed),
234
+ observedGrowthMB: Number((observed.at(-1) - observed[0]).toFixed(1)),
235
+ statusPeakMB: statusRss.length > 0 ? Math.max(...statusRss) : null,
236
+ psPeakMB: psRss.length > 0 ? Math.max(...psRss) : null,
237
+ pendingPeak: pendingChanges.length > 0 ? Math.max(...pendingChanges) : null,
238
+ };
239
+ }
240
+
241
+ const {DaemonClient} = await import(
242
+ path.join(REPO_ROOT, 'dist/client/index.js')
243
+ );
244
+ const client = new DaemonClient({
245
+ projectRoot: REPO_ROOT,
246
+ autoStart: true,
247
+ connectTimeout: 30_000,
248
+ clientSource: 'unknown',
249
+ });
250
+
251
+ const runId = new Date().toISOString().replace(/[:.]/g, '-');
252
+ const outputFile = path.join(OUTPUT_DIR, `watch-reindex-stress-${runId}.jsonl`);
253
+
254
+ const state = {
255
+ stop: false,
256
+ stopReason: null,
257
+ writesAttempted: 0,
258
+ writesSucceeded: 0,
259
+ writesFailed: 0,
260
+ activeWriters: 0,
261
+ maxActiveWriters: 0,
262
+ guardTriggered: false,
263
+ guardKind: null,
264
+ guardTriggeredAt: null,
265
+ guardObservedRssMB: null,
266
+ guardCancelError: null,
267
+ guardShutdownError: null,
268
+ guardKillError: null,
269
+ lastSeenPid: null,
270
+ lastObservedRssMB: null,
271
+ lastIndexUpdate: null,
272
+ indexUpdatesObserved: 0,
273
+ maxPendingChanges: 0,
274
+ indexingBusySamples: 0,
275
+ lastError: null,
276
+ errorBuckets: new Map(),
277
+ };
278
+
279
+ const samples = [];
280
+ const targetContentBytes = Math.max(256, Math.round(CONTENT_KB * 1024));
281
+ const fillerSeed = 'x'.repeat(Math.max(0, targetContentBytes));
282
+
283
+ async function writeRow(row) {
284
+ await fs.appendFile(outputFile, `${JSON.stringify(row)}\n`, 'utf8');
285
+ }
286
+
287
+ async function triggerGuard(kind, row) {
288
+ if (state.guardTriggered) {
289
+ return;
290
+ }
291
+ state.guardTriggered = true;
292
+ state.guardKind = kind;
293
+ state.guardTriggeredAt = row.ts;
294
+ state.guardObservedRssMB = row.observedRssMB;
295
+ state.stopReason = `${kind}_guard`;
296
+ state.stop = true;
297
+ row.guardTriggered = true;
298
+ row.guardKind = kind;
299
+
300
+ try {
301
+ await withTimeout(
302
+ client.cancel({
303
+ target: 'all',
304
+ reason: `${kind} watch stress guard triggered at ${row.observedRssMB}MB`,
305
+ }),
306
+ 1200,
307
+ 'cancel',
308
+ );
309
+ } catch (error) {
310
+ state.guardCancelError = compactError(error);
311
+ addErrorBucket(state, error);
312
+ }
313
+
314
+ try {
315
+ await withTimeout(
316
+ client.shutdown(`${kind} watch stress guard requested shutdown`),
317
+ 1200,
318
+ 'shutdown',
319
+ );
320
+ } catch (error) {
321
+ state.guardShutdownError = compactError(error);
322
+ addErrorBucket(state, error);
323
+ }
324
+
325
+ if (GUARD_KILL && Number.isInteger(state.lastSeenPid)) {
326
+ try {
327
+ process.kill(state.lastSeenPid, 'SIGTERM');
328
+ } catch (error) {
329
+ state.guardKillError = compactError(error);
330
+ addErrorBucket(state, error);
331
+ }
332
+ }
333
+ }
334
+
335
+ async function sample(label, elapsedSec) {
336
+ let status = null;
337
+ let statusError = null;
338
+ try {
339
+ status = await client.status();
340
+ } catch (error) {
341
+ statusError = compactError(error);
342
+ addErrorBucket(state, error);
343
+ }
344
+
345
+ let psSample = null;
346
+ let psError = null;
347
+ try {
348
+ psSample = await findDaemonProcess();
349
+ } catch (error) {
350
+ psError = compactError(error);
351
+ addErrorBucket(state, error);
352
+ }
353
+
354
+ const lastIndexUpdate = status?.watcherStatus?.lastIndexUpdate ?? null;
355
+ if (typeof lastIndexUpdate === 'string' && lastIndexUpdate.length > 0) {
356
+ if (lastIndexUpdate !== state.lastIndexUpdate) {
357
+ state.indexUpdatesObserved += 1;
358
+ }
359
+ state.lastIndexUpdate = lastIndexUpdate;
360
+ }
361
+
362
+ const pendingChanges = status?.watcherStatus?.pendingChanges ?? null;
363
+ if (Number.isFinite(pendingChanges)) {
364
+ state.maxPendingChanges = Math.max(state.maxPendingChanges, pendingChanges);
365
+ }
366
+
367
+ const indexingStatus = status?.indexing?.status ?? null;
368
+ if (
369
+ indexingStatus === 'initializing' ||
370
+ indexingStatus === 'indexing' ||
371
+ indexingStatus === 'cancelling'
372
+ ) {
373
+ state.indexingBusySamples += 1;
374
+ }
375
+
376
+ const row = {
377
+ ts: nowIso(),
378
+ label,
379
+ elapsedSec,
380
+ pid: psSample?.pid ?? null,
381
+ rssMBPs: psSample?.rssMB ?? null,
382
+ vszMBPs: psSample?.vszMB ?? null,
383
+ cpuPercentPs: psSample?.cpuPercent ?? null,
384
+ memPercentPs: psSample?.memPercent ?? null,
385
+ processStatePs: psSample?.state ?? null,
386
+ processEtimePs: psSample?.etime ?? null,
387
+ rssMBStatus: status?.memory?.rssMB ?? null,
388
+ heapUsedMBStatus: status?.memory?.heapUsedMB ?? null,
389
+ externalMBStatus: status?.memory?.externalMB ?? null,
390
+ arrayBuffersMBStatus: status?.memory?.arrayBuffersMB ?? null,
391
+ watcherWatching: status?.watcherStatus?.watching ?? null,
392
+ watcherFilesWatched: status?.watcherStatus?.filesWatched ?? null,
393
+ watcherPendingChanges: pendingChanges,
394
+ watcherLastIndexUpdate: lastIndexUpdate,
395
+ watcherIndexUpToDate: status?.watcherStatus?.indexUpToDate ?? null,
396
+ watcherLastError: status?.watcherStatus?.lastError ?? null,
397
+ watcherAutoIndexPausedUntil:
398
+ status?.watcherStatus?.autoIndexPausedUntil ?? null,
399
+ indexingStatus,
400
+ indexingPhase: status?.indexing?.phase ?? null,
401
+ indexingStage: status?.indexing?.stage ?? null,
402
+ indexingCurrent: status?.indexing?.current ?? null,
403
+ indexingTotal: status?.indexing?.total ?? null,
404
+ indexingLastCompleted: status?.indexing?.lastCompleted ?? null,
405
+ indexingLastFilesIndexed: status?.indexing?.lastStats?.filesIndexed ?? null,
406
+ indexingLastChunkRowsUpserted:
407
+ status?.indexing?.lastStats?.chunkRowsUpserted ?? null,
408
+ indexingLastChunkRowsDeleted:
409
+ status?.indexing?.lastStats?.chunkRowsDeleted ?? null,
410
+ indexUpdatesObserved: state.indexUpdatesObserved,
411
+ writesAttempted: state.writesAttempted,
412
+ writesSucceeded: state.writesSucceeded,
413
+ writesFailed: state.writesFailed,
414
+ activeWriters: state.activeWriters,
415
+ maxActiveWriters: state.maxActiveWriters,
416
+ statusError,
417
+ psError,
418
+ lastError: state.lastError,
419
+ };
420
+
421
+ if (Number.isInteger(psSample?.pid)) {
422
+ state.lastSeenPid = psSample.pid;
423
+ }
424
+
425
+ const observedCandidates = [row.rssMBPs, row.rssMBStatus].filter(
426
+ Number.isFinite,
427
+ );
428
+ row.observedRssMB =
429
+ observedCandidates.length > 0
430
+ ? Number(Math.max(...observedCandidates).toFixed(1))
431
+ : null;
432
+ if (Number.isFinite(row.observedRssMB)) {
433
+ state.lastObservedRssMB = row.observedRssMB;
434
+ }
435
+
436
+ if (!state.guardTriggered && Number.isFinite(row.observedRssMB)) {
437
+ if (row.observedRssMB >= MAX_RSS_MB) {
438
+ await triggerGuard('hard', row);
439
+ } else if (row.observedRssMB >= SOFT_RSS_MB) {
440
+ await triggerGuard('soft', row);
441
+ }
442
+ }
443
+
444
+ samples.push(row);
445
+ await writeRow(row);
446
+ console.log(JSON.stringify(row));
447
+ }
448
+
449
+ function buildFileContent(fileIndex, writerId, iteration) {
450
+ const stamp = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
451
+ const head = [
452
+ '// Auto-generated watcher reindex stress probe. Safe to delete.',
453
+ `export const WATCH_STRESS_FILE_${fileIndex} = ${iteration};`,
454
+ `export const WATCH_STRESS_WRITER_${fileIndex} = ${writerId};`,
455
+ `export const WATCH_STRESS_STAMP_${fileIndex} = '${stamp}';`,
456
+ ].join('\n');
457
+ const baseBytes = Buffer.byteLength(head, 'utf8') + 8;
458
+ const fillerLength = Math.max(0, targetContentBytes - baseBytes);
459
+ const filler = fillerSeed.slice(0, fillerLength);
460
+ return `${head}\n/*${filler}*/\n`;
461
+ }
462
+
463
+ async function runWriter(writerId, filePaths, deadlineMs) {
464
+ let iteration = 0;
465
+ state.activeWriters += 1;
466
+ state.maxActiveWriters = Math.max(
467
+ state.maxActiveWriters,
468
+ state.activeWriters,
469
+ );
470
+ try {
471
+ while (!state.stop && Date.now() < deadlineMs) {
472
+ const fileIndex = (writerId + iteration) % filePaths.length;
473
+ const filePath = filePaths[fileIndex];
474
+ state.writesAttempted += 1;
475
+
476
+ try {
477
+ await fs.writeFile(
478
+ filePath,
479
+ buildFileContent(fileIndex, writerId, iteration),
480
+ 'utf8',
481
+ );
482
+ state.writesSucceeded += 1;
483
+ } catch (error) {
484
+ state.writesFailed += 1;
485
+ state.lastError = compactError(error);
486
+ addErrorBucket(state, error);
487
+ await sleep(5);
488
+ }
489
+
490
+ iteration += 1;
491
+ if (WRITE_INTERVAL_MS > 0) {
492
+ await sleep(WRITE_INTERVAL_MS);
493
+ }
494
+ }
495
+ } finally {
496
+ state.activeWriters = Math.max(0, state.activeWriters - 1);
497
+ }
498
+ }
499
+
500
+ async function waitForWatcherReady(maxMs = 30_000) {
501
+ const started = Date.now();
502
+ while (Date.now() - started < maxMs) {
503
+ try {
504
+ const status = await client.status();
505
+ if (status?.watcherStatus?.watching) {
506
+ return status;
507
+ }
508
+ } catch {}
509
+ await sleep(200);
510
+ }
511
+ throw new Error('watcher did not become active before timeout');
512
+ }
513
+
514
+ let restartInfo = null;
515
+ let cleanupError = null;
516
+ let cleanupSucceeded = false;
517
+
518
+ try {
519
+ await fs.mkdir(OUTPUT_DIR, {recursive: true});
520
+ await fs.writeFile(outputFile, '', 'utf8');
521
+
522
+ if (CLEAN_START) {
523
+ restartInfo = await killDaemonProcesses();
524
+ }
525
+
526
+ await fs.rm(TARGET_DIR, {recursive: true, force: true});
527
+ await fs.mkdir(TARGET_DIR, {recursive: true});
528
+ const probeFiles = Array.from({length: FILE_COUNT}, (_, index) =>
529
+ path.join(TARGET_DIR, `probe-${index}.ts`),
530
+ );
531
+ await Promise.all(
532
+ probeFiles.map((filePath, index) =>
533
+ fs.writeFile(filePath, buildFileContent(index, -1, 0), 'utf8'),
534
+ ),
535
+ );
536
+
537
+ await withTimeout(client.connect(), 30_000, 'connect');
538
+ const readyStatus = await withTimeout(
539
+ waitForWatcherReady(),
540
+ 30_000,
541
+ 'watcher',
542
+ );
543
+ state.lastIndexUpdate = readyStatus?.watcherStatus?.lastIndexUpdate ?? null;
544
+
545
+ const startedAt = Date.now();
546
+ const deadlineMs = startedAt + DURATION_SECONDS * 1000;
547
+
548
+ const header = {
549
+ ts: nowIso(),
550
+ label: 'run.start',
551
+ durationSeconds: DURATION_SECONDS,
552
+ writerCount: WRITER_COUNT,
553
+ fileCount: FILE_COUNT,
554
+ writeIntervalMs: WRITE_INTERVAL_MS,
555
+ sampleMs: SAMPLE_MS,
556
+ contentKB: CONTENT_KB,
557
+ maxRssMB: MAX_RSS_MB,
558
+ softRssMB: SOFT_RSS_MB,
559
+ guardKill: GUARD_KILL,
560
+ cleanStart: CLEAN_START,
561
+ targetDir: TARGET_DIR_REL,
562
+ cooldownSeconds: COOLDOWN_SECONDS,
563
+ cleanupSettleSeconds: CLEANUP_SETTLE_SECONDS,
564
+ restartInfo,
565
+ outputFile,
566
+ };
567
+ await writeRow(header);
568
+ console.log(JSON.stringify(header));
569
+
570
+ await sample('baseline', 0);
571
+
572
+ const writers = Array.from({length: WRITER_COUNT}, (_, writerId) =>
573
+ runWriter(writerId, probeFiles, deadlineMs),
574
+ );
575
+
576
+ while (Date.now() < deadlineMs && !state.stop) {
577
+ await sleep(SAMPLE_MS);
578
+ const elapsedSec = Number(((Date.now() - startedAt) / 1000).toFixed(1));
579
+ await sample('active', elapsedSec);
580
+ }
581
+
582
+ if (!state.stopReason) {
583
+ state.stopReason = 'duration_complete';
584
+ }
585
+ state.stop = true;
586
+
587
+ const writersSettled = await Promise.race([
588
+ Promise.allSettled(writers).then(() => true),
589
+ sleep(20_000).then(() => false),
590
+ ]);
591
+
592
+ await sample(
593
+ 'post.active',
594
+ Number(((Date.now() - startedAt) / 1000).toFixed(1)),
595
+ );
596
+
597
+ if (COOLDOWN_SECONDS > 0 && !state.guardTriggered) {
598
+ const cooldownEnd = Date.now() + COOLDOWN_SECONDS * 1000;
599
+ while (Date.now() < cooldownEnd) {
600
+ await sleep(SAMPLE_MS);
601
+ const elapsedSec = Number(((Date.now() - startedAt) / 1000).toFixed(1));
602
+ await sample('cooldown', elapsedSec);
603
+ }
604
+ }
605
+
606
+ try {
607
+ await fs.rm(TARGET_DIR, {recursive: true, force: true});
608
+ cleanupSucceeded = true;
609
+ await sample(
610
+ 'cleanup.delete',
611
+ Number(((Date.now() - startedAt) / 1000).toFixed(1)),
612
+ );
613
+ } catch (error) {
614
+ cleanupError = compactError(error);
615
+ }
616
+
617
+ if (CLEANUP_SETTLE_SECONDS > 0 && !state.guardTriggered) {
618
+ const settleEnd = Date.now() + CLEANUP_SETTLE_SECONDS * 1000;
619
+ while (Date.now() < settleEnd) {
620
+ await sleep(SAMPLE_MS);
621
+ const elapsedSec = Number(((Date.now() - startedAt) / 1000).toFixed(1));
622
+ await sample('cleanup.settle', elapsedSec);
623
+ }
624
+ }
625
+
626
+ const writesPerSecond =
627
+ DURATION_SECONDS > 0
628
+ ? Number((state.writesSucceeded / DURATION_SECONDS).toFixed(1))
629
+ : null;
630
+
631
+ const summary = {
632
+ ts: nowIso(),
633
+ label: 'run.summary',
634
+ durationSeconds: DURATION_SECONDS,
635
+ writerCount: WRITER_COUNT,
636
+ fileCount: FILE_COUNT,
637
+ writesAttempted: state.writesAttempted,
638
+ writesSucceeded: state.writesSucceeded,
639
+ writesFailed: state.writesFailed,
640
+ writesPerSecond,
641
+ indexUpdatesObserved: state.indexUpdatesObserved,
642
+ maxPendingChanges: state.maxPendingChanges,
643
+ indexingBusySamples: state.indexingBusySamples,
644
+ stopReason: state.stopReason,
645
+ writersSettled,
646
+ guardTriggered: state.guardTriggered,
647
+ guardKind: state.guardKind,
648
+ guardTriggeredAt: state.guardTriggeredAt,
649
+ guardObservedRssMB: state.guardObservedRssMB,
650
+ guardCancelError: state.guardCancelError,
651
+ guardShutdownError: state.guardShutdownError,
652
+ guardKillError: state.guardKillError,
653
+ cleanupSucceeded,
654
+ cleanupError,
655
+ lastError: state.lastError,
656
+ errorBuckets: topErrorBuckets(state, 10),
657
+ memory: summarizeSamples(samples),
658
+ outputFile,
659
+ };
660
+ await writeRow(summary);
661
+ console.log(JSON.stringify(summary));
662
+
663
+ try {
664
+ await withTimeout(
665
+ client.shutdown('watch reindex stress script complete'),
666
+ 2000,
667
+ 'shutdown',
668
+ );
669
+ } catch (error) {
670
+ const row = {
671
+ ts: nowIso(),
672
+ label: 'shutdown.error',
673
+ error: compactError(error),
674
+ };
675
+ await writeRow(row);
676
+ console.log(JSON.stringify(row));
677
+ }
678
+ } finally {
679
+ try {
680
+ await fs.rm(TARGET_DIR, {recursive: true, force: true});
681
+ } catch {}
682
+ await client.disconnect().catch(() => {});
683
+ }