querysub 0.456.0 → 0.457.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.456.0",
3
+ "version": "0.457.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -516,8 +516,9 @@ export class ArchivesBackblaze {
516
516
  fnc: (api: B2Api) => Promise<T>,
517
517
  retries = 3
518
518
  ): Promise<T> {
519
- let api = await this.getBucketAPI();
519
+ let api: B2Api | undefined;
520
520
  try {
521
+ api = await this.getBucketAPI();
521
522
  return await fnc(api);
522
523
  } catch (err: any) {
523
524
  if (retries <= 0) throw err;
@@ -570,19 +571,21 @@ export class ArchivesBackblaze {
570
571
  }
571
572
 
572
573
  if (err.stack.includes(`getaddrinfo ENOTFOUND`)) {
573
- let urlObj = new URL(api.apiUrl);
574
- let hostname = urlObj.hostname;
575
- let lookupAddresses = await new Promise(resolve => {
576
- dns.lookup(hostname, (err, addresses) => {
577
- resolve(addresses);
574
+ if (api) {
575
+ let urlObj = new URL(api.apiUrl);
576
+ let hostname = urlObj.hostname;
577
+ let lookupAddresses = await new Promise(resolve => {
578
+ dns.lookup(hostname, (err, addresses) => {
579
+ resolve(addresses);
580
+ });
578
581
  });
579
- });
580
- let resolveAddresses = await new Promise(resolve => {
581
- dns.resolve4(hostname, (err, addresses) => {
582
- resolve(addresses);
582
+ let resolveAddresses = await new Promise(resolve => {
583
+ dns.resolve4(hostname, (err, addresses) => {
584
+ resolve(addresses);
585
+ });
583
586
  });
584
- });
585
- console.error(`[${context}] getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
587
+ console.error(`[${context}] getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
588
+ }
586
589
  }
587
590
 
588
591
  // NOTE: The AI thought case that happens when we run out of retries, that's stupid. This obviously isn't the case. This is the case when it's a normal error, as in the file doesn't exist, we need to throw. We absolutely should not warn here. Warning here wouldn't be anything. It would just be saying, oh, we checked if a file and it didn't, which is normal, which is why we check if a file exists.
@@ -997,23 +997,23 @@ class PathValueSerializer {
997
997
  public getPathValue(pathValue: PathValue | undefined, noMutate?: "noMutate"): unknown {
998
998
  if (!pathValue) return undefined;
999
999
  if (pathValue.isValueLazy) {
1000
- // NOTE: If this throws, it likely means you used atomicObjectWrite.
1001
- // Use atomicObjectWriteNoFreeze or doAtomicWrites instead.
1002
- if (!noMutate) {
1003
- try {
1004
- pathValue.isValueLazy = false;
1005
- } catch { }
1006
- }
1007
1000
  let buffer = this.lazyValues.get(pathValue.value as {});
1008
1001
  if (!buffer) {
1009
- console.error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
1010
- return pathValue.value;
1002
+ throw new Error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
1011
1003
  }
1012
1004
  let newValue = recursiveFreeze(cbor.decode(buffer));
1013
- if (!noMutate) {
1005
+ if (!noMutate && !pathValue.isValueLazy) {
1006
+ // NOTE: If this throws, it likely means you used atomicObjectWrite.
1007
+ // Use atomicObjectWriteNoFreeze or doAtomicWrites instead.
1014
1008
  try {
1009
+ pathValue.isValueLazy = false;
1015
1010
  pathValue.value = newValue;
1016
- } catch { }
1011
+ } catch {
1012
+ // In theory, it could be that you can set the is value lazy property but not the value property. In which case we want to reset the isValazy property back. Otherwise, we will no longer try to deserialize it, and we will emit the lazy placeholder instead of the actual value next time.
1013
+ try {
1014
+ pathValue.isValueLazy = true;
1015
+ } catch { }
1016
+ }
1017
1017
  }
1018
1018
  return newValue;
1019
1019
  }
@@ -1027,8 +1027,7 @@ class PathValueSerializer {
1027
1027
  // NOTE: Did you pass a raw PathValue and then try to use PathValueSerializer with it?
1028
1028
  // - Instead you should pass a buffer serialized with pathValueSerializer.serialize and
1029
1029
  // deserialized with pathValueSerializer.deserialize.
1030
- console.error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
1031
- return pathValue.value;
1030
+ throw new Error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
1032
1031
  }
1033
1032
  return buffer;
1034
1033
  }
@@ -1036,8 +1035,11 @@ class PathValueSerializer {
1036
1035
  }
1037
1036
 
1038
1037
  private getBuffer(pathValue: PathValue): Buffer {
1039
- let buffer = pathValue.isValueLazy && this.lazyValues.get(pathValue.value as {});
1040
- if (buffer) return buffer;
1038
+ if (pathValue.isValueLazy) {
1039
+ let buffer = this.lazyValues.get(pathValue.value as {});
1040
+ if (!buffer) throw new Error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
1041
+ return buffer;
1042
+ }
1041
1043
  return cborEncoder().encode(pathValue.value);
1042
1044
  }
1043
1045
  public compareValuePaths(a: PathValue | undefined, b: PathValue | undefined) {
@@ -746,7 +746,7 @@ class AuthorityPathValueStorage {
746
746
 
747
747
  if (isDebugLogEnabled() && !config?.doNotArchive) {
748
748
  for (let value of newValues) {
749
- auditLog("INGEST VALUE", { path: value.path, timeId: value.time.time });
749
+ auditLog("INGEST VALUE", { path: value.path, timeId: value.time.time, batchTime: now });
750
750
  }
751
751
  }
752
752
 
@@ -620,7 +620,17 @@ export class IndexedLogs<T> {
620
620
  dataReader,
621
621
  params: {
622
622
  ...config.params,
623
- limit: config.params.limit - results.matchCount,
623
+ // Per-file cap = global `limit`, NOT `limit - matchCount`.
624
+ // Subtracting the running count would starve parallel
625
+ // files: once one file fills matchCount, every other
626
+ // in-flight file gets limit=0 and stops scanning, even
627
+ // though `isSourceRelevant` may still want their earlier
628
+ // matches to displace the kept top-K. Within a single
629
+ // file blocks are time-ordered in the scan direction,
630
+ // so capping at `limit` per file is safe — anything
631
+ // past the first `limit` matches in this file couldn't
632
+ // survive the top-K sort.
633
+ limit: config.params.limit,
624
634
  },
625
635
  keepIterating: () => !results.cancel && progressTracker.isSourceRelevant(path),
626
636
  onResult: (match: Buffer) => {
@@ -38,7 +38,7 @@ import { startTimeParam, endTimeParam } from "../TimeRangeSelector";
38
38
  import { formatSearchString } from "./LogViewerParams";
39
39
 
40
40
  let excludePendingResults = new URLParam("excludePendingResults", false);
41
- let limitURL = new URLParam("limit", 16);
41
+ let limitURL = new URLParam("limit", 200);
42
42
  let enableLogsURL = new URLParam("enableLogs", true);
43
43
  let enableInfosURL = new URLParam("enableInfos", true);
44
44
  let enableWarningsURL = new URLParam("enableWarnings", true);
@@ -46,7 +46,6 @@ let enableErrorsURL = new URLParam("enableErrors", true);
46
46
 
47
47
  let savedPathsURL = new URLParam("savedPaths", "");
48
48
  let selectedFieldsURL = new URLParam("selectedFields", {} as Record<string, boolean>);
49
- let useRelativeTimeURL = new URLParam("useRelativeTime", true);
50
49
 
51
50
  // NOTE: Because this doesn't cache properly, it lags a lot, so we shouldn't try to use it.
52
51
  let showLifecycleColumnURL = new URLParam("showlifecycle", false);
@@ -269,7 +268,8 @@ export class LogViewer3 extends qreact.Component {
269
268
  let updateResults = throttleFunction(100, () => {
270
269
  if (this.searchSequenceNumber !== currentSequenceNumber) return;
271
270
  Querysub.commitLocal(() => {
272
- this.state.results = results;
271
+ // LIMIT, as the other values will be fragmented and so are confusing.
272
+ this.state.results = results.slice(0, limitURL.value);
273
273
  });
274
274
  });
275
275
 
@@ -495,17 +495,19 @@ export class LogViewer3 extends qreact.Component {
495
495
 
496
496
  for (let field of selectedFields) {
497
497
  let column: ColumnType<unknown, LogDatum> = {};
498
- if (field === "time") {
499
- column.formatter = (x: unknown) => useRelativeTimeURL.value ? formatDateJSX(Number(x)) : <span title={formatDateTimeDetailed(Number(x))}>{formatDateTime(Number(x))}</span>;
500
- } else if (field === "__machineId") {
501
- column.formatter = (x: unknown, context) => {
502
- if (!context?.row || !context.row.__machineId) return <ObjectDisplay value={x} />;
503
- return <MachineThreadInfo machineId={context.row.__machineId} threadId={context.row.__threadId || undefined} />;
504
- };
505
- }
506
- if (!column.formatter) {
507
- column.formatter = (x: unknown) => <ObjectDisplay value={x} />;
498
+ if (field === "time" || field === "timeId") {
499
+ column.formatter = (x: unknown) => <span title={formatDateTimeDetailed(Number(x))}>{formatDateTimeDetailed(Number(x))}</span>;
508
500
  }
501
+ // ObjectDisplay is too slow.
502
+ // else if (field === "__machineId") {
503
+ // column.formatter = (x: unknown, context) => {
504
+ // if (!context?.row || !context.row.__machineId) return <ObjectDisplay value={x} />;
505
+ // return <MachineThreadInfo machineId={context.row.__machineId} threadId={context.row.__threadId || undefined} />;
506
+ // };
507
+ // }
508
+ // if (!column.formatter) {
509
+ // column.formatter = (x: unknown) => <ObjectDisplay value={x} />;
510
+ // }
509
511
  columns[field] = column;
510
512
  }
511
513
 
@@ -622,11 +624,6 @@ export class LogViewer3 extends qreact.Component {
622
624
  selectedFieldsURL.value = newValues;
623
625
  }}
624
626
  />
625
- <InputLabelURL
626
- label="Use Relative Time"
627
- checkbox
628
- url={useRelativeTimeURL}
629
- />
630
627
  <InputLabelURL
631
628
  label="Show Lifecycles"
632
629
  checkbox
@@ -1,4 +1,4 @@
1
- import { timeInHour, timeInMinute, timeInSecond, timeoutToError, timeoutToUndefined, timeoutToUndefinedSilent } from "socket-function/src/misc";
1
+ import { sort, timeInHour, timeInMinute, timeInSecond, timeoutToError, timeoutToUndefined, timeoutToUndefinedSilent } from "socket-function/src/misc";
2
2
  import { lazy } from "socket-function/src/caching";
3
3
  import { getMachineId } from "../../../-a-auth/certs";
4
4
  import { getAllNodeIds, getOwnMachineId, isOwnNodeId } from "../../../-f-node-discovery/NodeDiscovery";
@@ -27,32 +27,6 @@ const PROGRESS_LOG_INTERVAL = timeInSecond * 5;
27
27
  const LOGGER_NAMES = ["logs/log", "logs/info", "logs/warn", "logs/error"] as const;
28
28
  type LoggerName = typeof LOGGER_NAMES[number];
29
29
 
30
- // Public-facing short names callers pass in via the `logTypes` parameter, mapped
31
- // to the internal logger names above.
32
- const EXTERNAL_TO_INTERNAL_LOGGER: Record<string, LoggerName> = {
33
- "log": "logs/log",
34
- "info": "logs/info",
35
- "warn": "logs/warn",
36
- "error": "logs/error",
37
- };
38
-
39
- // Parses the caller's `logTypes` string (e.g. "warn|error") into the matching
40
- // internal logger names. Empty / undefined means "all four" (no restriction).
41
- function parseLogTypes(value: string | undefined): readonly LoggerName[] {
42
- if (!value) return LOGGER_NAMES;
43
- let parts = value.split("|").map(s => s.trim().toLowerCase()).filter(s => s);
44
- if (parts.length === 0) return LOGGER_NAMES;
45
- let result: LoggerName[] = [];
46
- for (let p of parts) {
47
- let internal = EXTERNAL_TO_INTERNAL_LOGGER[p];
48
- if (!internal) {
49
- throw new Error(`logTypes: unknown log type ${JSON.stringify(p)}; expected one of log, info, warn, error (separated by "|")`);
50
- }
51
- if (!result.includes(internal)) result.push(internal);
52
- }
53
- return result;
54
- }
55
-
56
30
  // Per-logger accounting for one search. Byte counts are raw buffer sizes.
57
31
  export type LoggerStats = {
58
32
  // Files in range matching the requested machine.
@@ -168,11 +142,8 @@ function createProgressLogger(): (message: string) => void {
168
142
  }
169
143
 
170
144
  export class MCPIndexedLogs {
171
- // `${machineId}|${loggerName}` -> latest timestamp guaranteed to already be
172
- // moved-to-public for that specific (machine, logger). Keyed per-logger so
173
- // a search scoped to only some loggers doesn't poison the cache for the
174
- // others.
175
- private movedThroughByMachineLogger = new Map<string, number>();
145
+ // machineId -> latest timestamp guaranteed to already be moved-to-public.
146
+ private movedThroughByMachine = new Map<string, number>();
176
147
 
177
148
  // Cache: `${type}|${loggerName}|${startBucket}|${endBucket}` -> { time, paths }.
178
149
  // Buckets are hour-aligned start/end so adjacent searches reuse work.
@@ -186,15 +157,14 @@ export class MCPIndexedLogs {
186
157
  direction: Direction;
187
158
  columns: string[];
188
159
  limit?: number;
189
- // Optional pipe-separated list restricting which log streams to scan
190
- // (e.g. "warn|error", "log"). Omit / empty = all four streams.
191
- logTypes?: string;
192
160
  }): Promise<SearchResult> {
193
161
  let limit = config.limit ?? 100;
194
162
  let startTime = normalizeTime(config.startTime, "startTime");
195
163
  let endTime = normalizeTime(config.endTime, "endTime");
196
- let enabledLoggers = parseLogTypes(config.logTypes);
197
- console.log(`[search] query=${JSON.stringify(config.query)} | machine=${config.machine} | startTime=${formatDateTime(startTime)} | endTime=${formatDateTime(endTime)} | direction=${config.direction} | columns=[${config.columns.join(",")}] | limit=${config.limit ?? "(default)"} | logTypes=${config.logTypes ?? "(all)"}`);
164
+ // `time` is always projected — the final sort needs it, and callers can't
165
+ // meaningfully read a log row without knowing when it happened.
166
+ let columns = config.columns.includes("time") ? config.columns : ["time", ...config.columns];
167
+ console.log(`[search] query=${JSON.stringify(config.query)} | machine=${config.machine} | startTime=${formatDateTime(startTime)} | endTime=${formatDateTime(endTime)} | direction=${config.direction} | columns=[${config.columns.join(",")}] | limit=${config.limit ?? "(default)"}`);
198
168
  let now = Date.now();
199
169
  if (endTime > now - END_TIME_MIN_AGE) {
200
170
  throw new Error(`endTime must be at least ${formatTime(END_TIME_MIN_AGE)} in the past (got ${formatTime(now - endTime)} ago)`);
@@ -206,7 +176,7 @@ export class MCPIndexedLogs {
206
176
  let machineId = config.machine === "local" ? getOwnMachineId() : config.machine;
207
177
 
208
178
  let moveStart = Date.now();
209
- let moveOutcome = await this.ensureMovedThrough(machineId, endTime, enabledLoggers);
179
+ let moveOutcome = await this.ensureMovedThrough(machineId, endTime);
210
180
  console.log(`[search] ensureMovedThrough ${moveOutcome} in ${formatTime(Date.now() - moveStart)}`);
211
181
 
212
182
  let loggers = await getLoggers2Async();
@@ -223,7 +193,7 @@ export class MCPIndexedLogs {
223
193
 
224
194
  let pathsStart = Date.now();
225
195
  let totalPathsSeen = 0;
226
- await Promise.all(enabledLoggers.map(async (loggerName) => {
196
+ await Promise.all(LOGGER_NAMES.map(async (loggerName) => {
227
197
  let logger = this.getLoggerByName(loggers, loggerName);
228
198
  let archives = logger.debugGetCachedLogs({ type: useType });
229
199
 
@@ -270,6 +240,9 @@ export class MCPIndexedLogs {
270
240
  }));
271
241
  console.log(`[search] read ${allFiles.length} files in ${formatTime(Date.now() - searchStart)}`);
272
242
 
243
+ let dir = config.direction === "fromStart" ? 1 : -1;
244
+ sort(readFiles, x => x.entry.path.startTime * dir);
245
+
273
246
  // Phase 2: scan the already-read files in time order, applying a moving
274
247
  // cutoff once we have `limit` rows: any unprocessed file whose entire range
275
248
  // is past the cutoff cannot contribute results we'd keep.
@@ -282,7 +255,6 @@ export class MCPIndexedLogs {
282
255
  scanCount++;
283
256
  logScanProgress(`[search] scanning files ${scanCount}/${readFiles.length}`);
284
257
 
285
- if (resultRows.length >= limit) break;
286
258
  if (buffers === undefined) continue;
287
259
 
288
260
  if (cutoff !== undefined) {
@@ -303,7 +275,7 @@ export class MCPIndexedLogs {
303
275
  limit,
304
276
  queryBuffer,
305
277
  matchesPattern,
306
- columns: config.columns,
278
+ columns,
307
279
  startTime,
308
280
  endTime,
309
281
  stats,
@@ -326,18 +298,26 @@ export class MCPIndexedLogs {
326
298
  let totals = createEmptyLoggerStats();
327
299
  for (let name of LOGGER_NAMES) addLoggerStats(totals, fileCounts[name]);
328
300
 
329
- let limitHit = resultRows.length >= limit;
330
- console.log(`[search] done in ${formatTime(Date.now() - searchStart)} (filesScanned=${totals.scanned}/${allFiles.length} scannedBytes=${formatNumber(totals.scannedBytes)}B blocksMatched=${totals.blocksMatched} blocksRead=${totals.blocksRead} blockBytesRead=${formatNumber(totals.blockBytesRead)}B results=${resultRows.length} limit=${limit}${limitHit ? " HIT" : ""})`);
301
+ // Files from different loggers can overlap in time, so rows come out of
302
+ // phase 2 only roughly time-ordered. Sort by row time in the scan
303
+ // direction and slice to `limit`. The slice is the *only* place the
304
+ // global limit is enforced on the returned set — per-file/per-block caps
305
+ // upstream are bounded by `limit` to keep memory sane but don't define
306
+ // truncation by themselves.
307
+ sort(resultRows, r => Number(r.time) * dir);
308
+ let totalMatched = resultRows.length;
309
+ if (totalMatched > limit) resultRows = resultRows.slice(0, limit);
310
+
311
+ let limitHit = totalMatched > limit;
312
+ console.log(`[search] done in ${formatTime(Date.now() - searchStart)} (filesScanned=${totals.scanned}/${allFiles.length} scannedBytes=${formatNumber(totals.scannedBytes)}B blocksMatched=${totals.blocksMatched} blocksRead=${totals.blocksRead} blockBytesRead=${formatNumber(totals.blockBytesRead)}B matched=${totalMatched} returned=${resultRows.length} limit=${limit}${limitHit ? " HIT" : ""})`);
331
313
  console.log(`[search] buffer types: stream=${stats.typeCounts.stream} bulk=${stats.typeCounts.bulk}`);
332
314
  console.log(`[search] timing: readFiles=${formatTime(totals.readFilesMs)} findMatchingBlocks=${formatTime(totals.findMatchingBlocksMs)} getBlockBuffers=${formatTime(totals.getBlockBuffersMs)}`);
333
315
 
334
316
  // Trim the internal LoggerStats down to just total + scanned. The rest
335
317
  // (bytes/blocks/timing) stays in the console.log above and is NOT
336
- // returned — see the warning on SearchResult. We only emit entries for
337
- // the loggers we actually searched, so a caller who scoped to
338
- // `warn|error` doesn't see misleading 0s for the loggers they skipped.
318
+ // returned — see the warning on SearchResult.
339
319
  let files: Record<string, { total: number; scanned: number }> = {};
340
- for (let name of enabledLoggers) {
320
+ for (let name of LOGGER_NAMES) {
341
321
  files[name] = { total: fileCounts[name].total, scanned: fileCounts[name].scanned };
342
322
  }
343
323
 
@@ -387,8 +367,13 @@ export class MCPIndexedLogs {
387
367
  sink: SearchSink;
388
368
  }): Promise<void> {
389
369
  let { entry, indexBuf, dataBuf, direction, limit, queryBuffer, matchesPattern, columns, startTime, endTime, stats, sink } = scan;
390
- let { resultRows, loggerStats } = sink;
370
+ let { loggerStats } = sink;
391
371
  let p = entry.path;
372
+ // Per-file cap. Blocks are scanned in time order, so anything past the
373
+ // first `limit` rows from this file would lose the caller's final
374
+ // sort+slice anyway. Tracked locally (not against the shared sink)
375
+ // so a noisy earlier file doesn't starve overlapping later files.
376
+ let rowsFromThisFile = 0;
392
377
 
393
378
  // Region 1: the index scan that picks candidate blocks.
394
379
  let findStart = Date.now();
@@ -421,7 +406,7 @@ export class MCPIndexedLogs {
421
406
  }
422
407
 
423
408
  for (let block of blocks) {
424
- if (resultRows.length >= limit) break;
409
+ if (rowsFromThisFile >= limit) break;
425
410
 
426
411
  // Region 2: decoding the candidate block's buffers.
427
412
  let buffers: Buffer[] | undefined;
@@ -441,8 +426,10 @@ export class MCPIndexedLogs {
441
426
 
442
427
  let ordered = direction === "fromStart" ? buffers : [...buffers].reverse();
443
428
  for (let buf of ordered) {
444
- if (resultRows.length >= limit) break;
429
+ if (rowsFromThisFile >= limit) break;
430
+ let before = sink.resultRows.length;
445
431
  this.appendRow({ buf, matchesPattern, columns, startTime, endTime, sink });
432
+ if (sink.resultRows.length > before) rowsFromThisFile++;
446
433
  }
447
434
  }
448
435
  loggerStats.getBlockBuffersMs += Date.now() - blockStart;
@@ -485,20 +472,16 @@ export class MCPIndexedLogs {
485
472
  sink.loggerStats.rows++;
486
473
  }
487
474
 
488
- // For each requested logger, asks each remote node on the target machine
489
- // whether it has pending logs overlapping [0, endTime]. The first node that
490
- // answers without throwing wins; if it says yes, we ask the same node to
491
- // flush. We iterate because not every node necessarily exposes the new
492
- // endpoints (e.g. older versions still running). Records moved-through up
493
- // to now - MOVE_GRACE per (machine, logger) so we skip this on subsequent
494
- // calls covering the same window. Only the loggers listed in `loggers` are
495
- // touched; the others aren't queried or flushed.
496
- private async ensureMovedThrough(machineId: string, endTime: number, loggers: readonly LoggerName[]): Promise<"cached" | "no-node" | "moved"> {
497
- let needed = loggers.filter(name => {
498
- let lastMoved = this.movedThroughByMachineLogger.get(`${machineId}|${name}`) ?? 0;
499
- return lastMoved < endTime;
500
- });
501
- if (needed.length === 0) return "cached";
475
+ // For each logger, asks each remote node on the target machine whether it
476
+ // has pending logs overlapping [0, endTime]. The first node that answers
477
+ // without throwing wins; if it says yes, we ask the same node to flush.
478
+ // We iterate because not every node necessarily exposes the new endpoints
479
+ // (e.g. older versions still running). Records moved-through up to
480
+ // now - MOVE_GRACE so we skip this on subsequent calls covering the same
481
+ // window.
482
+ private async ensureMovedThrough(machineId: string, endTime: number): Promise<"cached" | "no-node" | "moved"> {
483
+ let lastMoved = this.movedThroughByMachine.get(machineId) ?? 0;
484
+ if (lastMoved >= endTime) return "cached";
502
485
 
503
486
  let nodeIds = await this.findRemoteNodesOnMachine(machineId);
504
487
  if (nodeIds.length === 0) {
@@ -506,7 +489,7 @@ export class MCPIndexedLogs {
506
489
  return "no-node";
507
490
  }
508
491
 
509
- for (let loggerName of needed) {
492
+ for (let loggerName of LOGGER_NAMES) {
510
493
  let answered = false;
511
494
  for (let nodeId of nodeIds) {
512
495
  try {
@@ -534,10 +517,7 @@ export class MCPIndexedLogs {
534
517
  }
535
518
  }
536
519
 
537
- let recordTime = Date.now() - MOVE_GRACE;
538
- for (let loggerName of needed) {
539
- this.movedThroughByMachineLogger.set(`${machineId}|${loggerName}`, recordTime);
540
- }
520
+ this.movedThroughByMachine.set(machineId, Date.now() - MOVE_GRACE);
541
521
  return "moved";
542
522
  }
543
523