@thru/replay 0.2.32 → 0.2.33

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/dist/index.mjs CHANGED
@@ -442,6 +442,7 @@ var DEFAULT_METRICS = {
442
442
  emittedReconnect: 0,
443
443
  discardedDuplicates: 0
444
444
  };
445
+ var RECONNECT_STUCK_THRESHOLD_MS = 3e4;
445
446
  function compareBigint(a, b) {
446
447
  if (a === b) return 0;
447
448
  return a < b ? -1 : 1;
@@ -643,6 +644,7 @@ var ReplayStream = class {
643
644
  this.logger.info("replay entering STREAMING state");
644
645
  const retryConfig = DEFAULT_RETRY_CONFIG;
645
646
  let retryAttempt = 0;
647
+ let waitingFirstReconnectEvent = null;
646
648
  while (true) {
647
649
  if (shouldStop()) return;
648
650
  try {
@@ -657,6 +659,16 @@ var ReplayStream = class {
657
659
  throw new Error("stream ended");
658
660
  }
659
661
  const slot = extractSlot(next.value);
662
+ if (waitingFirstReconnectEvent) {
663
+ this.logger.info("Replay stream reconnect completed", {
664
+ event: "replay.stream.reconnect.completed",
665
+ attempt: waitingFirstReconnectEvent.attempt,
666
+ resume_slot: waitingFirstReconnectEvent.resumeSlot.toString(),
667
+ first_event_slot: slot.toString(),
668
+ duration_ms: Date.now() - waitingFirstReconnectEvent.startedAtMs
669
+ });
670
+ waitingFirstReconnectEvent = null;
671
+ }
660
672
  const key = keyOf(next.value);
661
673
  if (seenItem(slot, key)) {
662
674
  this.metrics.discardedDuplicates += 1;
@@ -671,13 +683,45 @@ var ReplayStream = class {
671
683
  if (shouldStop(err)) return;
672
684
  const errMsg = err instanceof Error ? err.message : String(err);
673
685
  const backoffMs = calculateBackoff(retryAttempt, retryConfig);
674
- this.logger.warn(
675
- `live stream disconnected (${errMsg}); reconnecting in ${backoffMs}ms from slot ${currentSlot} (attempt ${retryAttempt + 1})`
676
- );
686
+ const attempt = retryAttempt + 1;
687
+ const reconnectStartedAtMs = Date.now();
688
+ this.logger.warn("Replay stream reconnect started", {
689
+ event: "replay.stream.reconnect.started",
690
+ reason: errMsg === "stream ended" ? "stream_ended" : "stream_error",
691
+ error: errMsg,
692
+ backoff_ms: backoffMs,
693
+ attempt,
694
+ current_slot: currentSlot.toString()
695
+ });
677
696
  await abortableDelay(backoffMs, signal);
678
697
  if (shouldStop()) return;
698
+ const cleanupStartedAtMs = Date.now();
699
+ this.logger.info("Replay stream reconnect cleanup started", {
700
+ event: "replay.stream.reconnect.cleanup_started",
701
+ phase: "live_pump_close",
702
+ attempt,
703
+ current_slot: currentSlot.toString()
704
+ });
679
705
  currentDispose();
680
- await safeClose(livePump);
706
+ const closeResult = await safeClose(livePump, signal);
707
+ const cleanupDurationMs = Date.now() - cleanupStartedAtMs;
708
+ if (closeResult === "timed-out") {
709
+ this.logger.warn("Replay stream reconnect cleanup stuck", {
710
+ event: "replay.stream.reconnect.stuck",
711
+ phase: "live_pump_close",
712
+ attempt,
713
+ duration_ms: cleanupDurationMs,
714
+ timeout_ms: RECONNECT_STUCK_THRESHOLD_MS
715
+ });
716
+ }
717
+ this.logger.info("Replay stream reconnect cleanup completed", {
718
+ event: "replay.stream.reconnect.cleanup_completed",
719
+ phase: "live_pump_close",
720
+ attempt,
721
+ duration_ms: cleanupDurationMs,
722
+ result: closeResult
723
+ });
724
+ if (shouldStop()) return;
681
725
  retryAttempt++;
682
726
  if (onReconnect) {
683
727
  try {
@@ -688,11 +732,16 @@ var ReplayStream = class {
688
732
  }
689
733
  currentDispose = fresh.dispose ?? (() => {
690
734
  });
691
- this.logger.info("created fresh client for reconnection");
735
+ this.logger.info("Replay stream fresh reconnect sources created", {
736
+ event: "replay.stream.reconnect.sources_created",
737
+ attempt
738
+ });
692
739
  } catch (factoryErr) {
693
- this.logger.error(
694
- `failed to create fresh client: ${factoryErr instanceof Error ? factoryErr.message : String(factoryErr)}; using existing`
695
- );
740
+ this.logger.error("Replay stream fresh reconnect sources failed", {
741
+ event: "replay.stream.reconnect.sources_failed",
742
+ attempt,
743
+ error: factoryErr
744
+ });
696
745
  }
697
746
  }
698
747
  if (onReconnect && currentSlot > 0n) {
@@ -715,11 +764,21 @@ var ReplayStream = class {
715
764
  }
716
765
  const resumeSlot = currentSlot > 0n ? currentSlot : 0n;
717
766
  livePump = createLivePump(resumeSlot, true, currentSlot);
767
+ waitingFirstReconnectEvent = {
768
+ startedAtMs: reconnectStartedAtMs,
769
+ attempt,
770
+ resumeSlot
771
+ };
772
+ this.logger.info("Replay stream waiting for first event", {
773
+ event: "replay.stream.waiting_first_event",
774
+ attempt,
775
+ resume_slot: resumeSlot.toString()
776
+ });
718
777
  }
719
778
  }
720
779
  } finally {
721
780
  currentDispose();
722
- await safeClose(livePump);
781
+ await safeClose(livePump, signal);
723
782
  }
724
783
  }
725
784
  /**
@@ -773,24 +832,45 @@ var ReplayStream = class {
773
832
  }
774
833
  }
775
834
  };
776
- async function safeClose(pump) {
835
+ async function safeClose(pump, signal) {
777
836
  let timeoutId;
837
+ let onAbort;
778
838
  try {
839
+ if (signal?.aborted) {
840
+ return "aborted";
841
+ }
779
842
  const timeout = new Promise((resolve) => {
780
- timeoutId = setTimeout(() => resolve("timeout"), 5e3);
843
+ timeoutId = setTimeout(() => resolve("timeout"), RECONNECT_STUCK_THRESHOLD_MS);
781
844
  });
845
+ const abort = signal ? new Promise((resolve) => {
846
+ onAbort = () => resolve("aborted");
847
+ signal.addEventListener("abort", onAbort, { once: true });
848
+ }) : null;
849
+ const close = pump.close().then(
850
+ () => "closed",
851
+ () => "closed"
852
+ );
782
853
  const result = await Promise.race([
783
- pump.close().then(() => "closed"),
784
- timeout
854
+ close,
855
+ timeout,
856
+ ...abort ? [abort] : []
785
857
  ]);
858
+ if (result === "aborted") {
859
+ return "aborted";
860
+ }
786
861
  if (result === "timeout") {
862
+ return "timed-out";
787
863
  }
788
864
  } catch {
789
865
  } finally {
790
866
  if (timeoutId !== void 0) {
791
867
  clearTimeout(timeoutId);
792
868
  }
869
+ if (signal && onAbort) {
870
+ signal.removeEventListener("abort", onAbort);
871
+ }
793
872
  }
873
+ return "closed";
794
874
  }
795
875
  function combineFilters(base, user) {
796
876
  if (!base && !user) return void 0;
@@ -1268,6 +1348,7 @@ var PageAssembler = class {
1268
1348
  };
1269
1349
 
1270
1350
  // src/account-replay.ts
1351
+ var DEFAULT_RECONNECT_CLEANUP_TIMEOUT_MS = 3e4;
1271
1352
  async function closeAsyncIterator(iterator) {
1272
1353
  if (!iterator || typeof iterator.return !== "function") {
1273
1354
  return;
@@ -1277,6 +1358,72 @@ async function closeAsyncIterator(iterator) {
1277
1358
  } catch {
1278
1359
  }
1279
1360
  }
1361
+ async function waitForCleanup(promise, timeoutMs, label, logger, signal) {
1362
+ const startedAtMs = Date.now();
1363
+ logger.info("Replay stream reconnect cleanup started", {
1364
+ event: "replay.stream.reconnect.cleanup_started",
1365
+ phase: label,
1366
+ timeout_ms: timeoutMs
1367
+ });
1368
+ if (signal?.aborted) {
1369
+ logger.debug("Replay stream reconnect cleanup aborted", {
1370
+ event: "replay.stream.reconnect.cleanup_completed",
1371
+ phase: label,
1372
+ timeout_ms: timeoutMs,
1373
+ result: "aborted",
1374
+ duration_ms: 0
1375
+ });
1376
+ return "aborted";
1377
+ }
1378
+ let timer = null;
1379
+ let onAbort = null;
1380
+ const timeoutPromise = new Promise((resolve) => {
1381
+ timer = setTimeout(() => resolve("timed-out"), timeoutMs);
1382
+ timer.unref?.();
1383
+ });
1384
+ const abortPromise = signal ? new Promise((resolve) => {
1385
+ onAbort = () => resolve("aborted");
1386
+ signal.addEventListener("abort", onAbort, { once: true });
1387
+ }) : null;
1388
+ const completionPromise = promise.then(
1389
+ () => "completed",
1390
+ () => "completed"
1391
+ );
1392
+ const result = await Promise.race(
1393
+ abortPromise ? [completionPromise, timeoutPromise, abortPromise] : [completionPromise, timeoutPromise]
1394
+ );
1395
+ if (timer) {
1396
+ clearTimeout(timer);
1397
+ }
1398
+ if (signal && onAbort) {
1399
+ signal.removeEventListener("abort", onAbort);
1400
+ }
1401
+ if (result === "timed-out") {
1402
+ logger.warn("Replay stream reconnect cleanup stuck", {
1403
+ event: "replay.stream.reconnect.stuck",
1404
+ phase: label,
1405
+ timeout_ms: timeoutMs,
1406
+ duration_ms: Date.now() - startedAtMs
1407
+ });
1408
+ } else if (result === "aborted") {
1409
+ logger.debug("Replay stream reconnect cleanup aborted", {
1410
+ event: "replay.stream.reconnect.cleanup_completed",
1411
+ phase: label,
1412
+ timeout_ms: timeoutMs,
1413
+ result,
1414
+ duration_ms: Date.now() - startedAtMs
1415
+ });
1416
+ } else {
1417
+ logger.info("Replay stream reconnect cleanup completed", {
1418
+ event: "replay.stream.reconnect.cleanup_completed",
1419
+ phase: label,
1420
+ timeout_ms: timeoutMs,
1421
+ result,
1422
+ duration_ms: Date.now() - startedAtMs
1423
+ });
1424
+ }
1425
+ return result;
1426
+ }
1280
1427
  function bytesToHex2(bytes) {
1281
1428
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1282
1429
  }
@@ -1349,6 +1496,8 @@ async function* createAccountsByOwnerReplay(options) {
1349
1496
  maxRetries = 3,
1350
1497
  pageAssemblerOptions,
1351
1498
  cleanupInterval = 1e4,
1499
+ reconnectCleanupTimeoutMs = DEFAULT_RECONNECT_CLEANUP_TIMEOUT_MS,
1500
+ retryConfig = DEFAULT_RETRY_CONFIG,
1352
1501
  onBackfillComplete,
1353
1502
  clientFactory,
1354
1503
  logger = NOOP_LOGGER,
@@ -1368,23 +1517,98 @@ async function* createAccountsByOwnerReplay(options) {
1368
1517
  let lastActivityTime = Date.now();
1369
1518
  let activeStreamIterator = null;
1370
1519
  let activeStreamProcessor = null;
1371
- try {
1520
+ let streamGeneration = 0;
1521
+ let retryAttempt = 0;
1522
+ const retireActiveStream = () => {
1523
+ const iterator = activeStreamIterator;
1524
+ const processor = activeStreamProcessor;
1525
+ activeStreamIterator = null;
1526
+ activeStreamProcessor = null;
1527
+ streamGeneration++;
1528
+ return { iterator, processor };
1529
+ };
1530
+ const cleanupRetiredStream = async (retired, iteratorLabel, processorLabel) => {
1531
+ if (retired.iterator) {
1532
+ await waitForCleanup(
1533
+ closeAsyncIterator(retired.iterator),
1534
+ reconnectCleanupTimeoutMs,
1535
+ iteratorLabel,
1536
+ logger,
1537
+ signal
1538
+ );
1539
+ }
1372
1540
  if (shouldStop()) return;
1373
- cleanupTimer = setInterval(() => {
1374
- assembler.cleanup();
1375
- }, cleanupInterval);
1376
- cleanupTimer.unref?.();
1377
- const streamFilter = buildOwnerFilterWithMinSlot(owner, dataSizes, minUpdatedSlot);
1378
- const stream = client.streamAccountUpdates({ view, filter: streamFilter });
1379
- const streamIterator = stream[Symbol.asyncIterator]();
1380
- activeStreamIterator = streamIterator;
1381
- activeStreamProcessor = (async () => {
1541
+ if (retired.processor) {
1542
+ await waitForCleanup(
1543
+ Promise.allSettled([retired.processor]),
1544
+ reconnectCleanupTimeoutMs,
1545
+ processorLabel,
1546
+ logger,
1547
+ signal
1548
+ );
1549
+ }
1550
+ };
1551
+ const createFreshClient = () => {
1552
+ if (!clientFactory) {
1553
+ return;
1554
+ }
1555
+ logger.info("Replay stream fresh client creation started", {
1556
+ event: "replay.stream.reconnect.client_started"
1557
+ });
1558
+ try {
1559
+ const previousClient = client;
1560
+ const newClient = clientFactory();
1561
+ closeIfCloseable(previousClient);
1562
+ client = newClient;
1563
+ logger.info("Replay stream fresh client creation completed", {
1564
+ event: "replay.stream.reconnect.client_completed"
1565
+ });
1566
+ } catch (err) {
1567
+ logger.error("Replay stream fresh client creation failed", {
1568
+ event: "replay.stream.reconnect.client_failed",
1569
+ error: err
1570
+ });
1571
+ }
1572
+ };
1573
+ const createStreamProcessor = (reason = "initial") => {
1574
+ const minSlot = highestSlotSeen > 0n ? highestSlotSeen : minUpdatedSlot;
1575
+ const generation = ++streamGeneration;
1576
+ const streamStartedAtMs = Date.now();
1577
+ let firstMessageSeen = false;
1578
+ if (reason === "reconnect") {
1579
+ logger.info("Replay stream waiting for first event", {
1580
+ event: "replay.stream.waiting_first_event",
1581
+ generation,
1582
+ min_slot: minSlot?.toString()
1583
+ });
1584
+ }
1585
+ const newStreamFilter = buildOwnerFilterWithMinSlot(owner, dataSizes, minSlot);
1586
+ const newStream = client.streamAccountUpdates({ view, filter: newStreamFilter });
1587
+ const newStreamIterator = newStream[Symbol.asyncIterator]();
1588
+ streamDone = false;
1589
+ streamError = null;
1590
+ lastActivityTime = Date.now();
1591
+ activeStreamIterator = newStreamIterator;
1592
+ const newProcessor = (async () => {
1382
1593
  try {
1383
1594
  while (true) {
1384
- const next = await streamIterator.next();
1595
+ const next = await newStreamIterator.next();
1596
+ if (generation !== streamGeneration) {
1597
+ return;
1598
+ }
1385
1599
  if (next.done) break;
1386
1600
  const response = next.value;
1601
+ retryAttempt = 0;
1387
1602
  lastActivityTime = Date.now();
1603
+ if (reason === "reconnect" && !firstMessageSeen) {
1604
+ firstMessageSeen = true;
1605
+ logger.info("Replay stream reconnect completed", {
1606
+ event: "replay.stream.reconnect.completed",
1607
+ generation,
1608
+ first_message: true,
1609
+ duration_ms: Date.now() - streamStartedAtMs
1610
+ });
1611
+ }
1388
1612
  const event = processResponseMulti(response, assembler);
1389
1613
  if (event) {
1390
1614
  if (event.type === "account") {
@@ -1397,11 +1621,24 @@ async function* createAccountsByOwnerReplay(options) {
1397
1621
  }
1398
1622
  }
1399
1623
  } catch (err) {
1400
- streamError = err;
1624
+ if (generation === streamGeneration) {
1625
+ streamError = err;
1626
+ }
1401
1627
  } finally {
1402
- streamDone = true;
1628
+ if (generation === streamGeneration) {
1629
+ streamDone = true;
1630
+ }
1403
1631
  }
1404
1632
  })();
1633
+ activeStreamProcessor = newProcessor;
1634
+ };
1635
+ try {
1636
+ if (shouldStop()) return;
1637
+ cleanupTimer = setInterval(() => {
1638
+ assembler.cleanup();
1639
+ }, cleanupInterval);
1640
+ cleanupTimer.unref?.();
1641
+ createStreamProcessor();
1405
1642
  const yieldStreamBuffer = function* () {
1406
1643
  while (streamBuffer.length > 0) {
1407
1644
  const event = streamBuffer.shift();
@@ -1479,51 +1716,8 @@ async function* createAccountsByOwnerReplay(options) {
1479
1716
  if (onBackfillComplete) {
1480
1717
  onBackfillComplete(highestSlotSeen);
1481
1718
  }
1482
- const retryConfig = DEFAULT_RETRY_CONFIG;
1483
- let retryAttempt = 0;
1719
+ retryAttempt = 0;
1484
1720
  lastActivityTime = Date.now();
1485
- const createStreamProcessor = () => {
1486
- if (clientFactory) {
1487
- try {
1488
- const newClient = clientFactory();
1489
- closeIfCloseable(client);
1490
- client = newClient;
1491
- logger.info("[account-stream] created fresh client for reconnection");
1492
- } catch (err) {
1493
- logger.error("[account-stream] failed to create fresh client", { error: err });
1494
- }
1495
- }
1496
- const newStreamFilter = buildOwnerFilterWithMinSlot(owner, dataSizes, highestSlotSeen > 0n ? highestSlotSeen : minUpdatedSlot);
1497
- const newStream = client.streamAccountUpdates({ view, filter: newStreamFilter });
1498
- const newStreamIterator = newStream[Symbol.asyncIterator]();
1499
- activeStreamIterator = newStreamIterator;
1500
- const newProcessor = (async () => {
1501
- try {
1502
- while (true) {
1503
- const next = await newStreamIterator.next();
1504
- if (next.done) break;
1505
- const response = next.value;
1506
- retryAttempt = 0;
1507
- lastActivityTime = Date.now();
1508
- const event = processResponseMulti(response, assembler);
1509
- if (event) {
1510
- if (event.type === "account") {
1511
- seenFromStream.add(event.account.addressHex);
1512
- if (event.account.slot > highestSlotSeen) {
1513
- highestSlotSeen = event.account.slot;
1514
- }
1515
- }
1516
- streamBuffer.push(event);
1517
- }
1518
- }
1519
- } catch (err) {
1520
- streamError = err;
1521
- } finally {
1522
- streamDone = true;
1523
- }
1524
- })();
1525
- return { iterator: newStreamIterator, processor: newProcessor };
1526
- };
1527
1721
  while (true) {
1528
1722
  if (shouldStop()) return;
1529
1723
  const hadEvents = streamBuffer.length > 0;
@@ -1531,10 +1725,13 @@ async function* createAccountsByOwnerReplay(options) {
1531
1725
  if (hadEvents) {
1532
1726
  lastActivityTime = Date.now();
1533
1727
  }
1534
- if (!streamDone && Date.now() - lastActivityTime > retryConfig.connectionTimeoutMs) {
1535
- logger.warn(
1536
- `[account-stream] no activity for ${retryConfig.connectionTimeoutMs}ms; forcing reconnection`
1537
- );
1728
+ const idleMs = Date.now() - lastActivityTime;
1729
+ if (!streamDone && idleMs > retryConfig.connectionTimeoutMs) {
1730
+ logger.warn("Replay stream idle timeout detected", {
1731
+ event: "replay.stream.idle_timeout",
1732
+ idleMs,
1733
+ connectionTimeoutMs: retryConfig.connectionTimeoutMs
1734
+ });
1538
1735
  streamDone = true;
1539
1736
  streamError = new Error(`Operation timed out after ${retryConfig.connectionTimeoutMs}ms`);
1540
1737
  }
@@ -1542,36 +1739,44 @@ async function* createAccountsByOwnerReplay(options) {
1542
1739
  if (streamError) {
1543
1740
  if (shouldStop(streamError)) return;
1544
1741
  const backoffMs = calculateBackoff(retryAttempt, retryConfig);
1545
- logger.warn(
1546
- `[account-stream] disconnected (${streamError.message}); reconnecting in ${backoffMs}ms (attempt ${retryAttempt + 1})`
1547
- );
1742
+ logger.warn("Replay stream reconnect started", {
1743
+ event: "replay.stream.reconnect.started",
1744
+ reason: "stream_error",
1745
+ error: streamError.message,
1746
+ backoffMs,
1747
+ attempt: retryAttempt + 1,
1748
+ highestSlotSeen: highestSlotSeen.toString()
1749
+ });
1548
1750
  await abortableDelay(backoffMs, signal);
1549
1751
  if (shouldStop()) return;
1550
1752
  retryAttempt++;
1753
+ const retired = retireActiveStream();
1551
1754
  streamDone = false;
1552
1755
  streamError = null;
1553
1756
  streamBuffer.length = 0;
1554
1757
  lastActivityTime = Date.now();
1555
- await closeAsyncIterator(activeStreamIterator);
1556
- if (activeStreamProcessor) {
1557
- await Promise.allSettled([activeStreamProcessor]);
1558
- }
1559
- const { iterator: newIterator, processor: newProcessor } = createStreamProcessor();
1560
- activeStreamIterator = newIterator;
1561
- activeStreamProcessor = newProcessor;
1758
+ await cleanupRetiredStream(retired, "old iterator close", "old processor drain");
1759
+ if (shouldStop()) return;
1760
+ createFreshClient();
1761
+ createStreamProcessor("reconnect");
1562
1762
  continue;
1563
1763
  } else {
1564
1764
  if (shouldStop()) return;
1565
- logger.warn("[account-stream] stream ended unexpectedly; reconnecting...");
1765
+ logger.warn("Replay stream reconnect started", {
1766
+ event: "replay.stream.reconnect.started",
1767
+ reason: "stream_ended",
1768
+ attempt: retryAttempt + 1,
1769
+ highestSlotSeen: highestSlotSeen.toString()
1770
+ });
1771
+ const retired = retireActiveStream();
1566
1772
  streamDone = false;
1773
+ streamError = null;
1774
+ streamBuffer.length = 0;
1567
1775
  lastActivityTime = Date.now();
1568
- await closeAsyncIterator(activeStreamIterator);
1569
- if (activeStreamProcessor) {
1570
- await Promise.allSettled([activeStreamProcessor]);
1571
- }
1572
- const { iterator: newIterator, processor: newProcessor } = createStreamProcessor();
1573
- activeStreamIterator = newIterator;
1574
- activeStreamProcessor = newProcessor;
1776
+ await cleanupRetiredStream(retired, "old iterator close", "old processor drain");
1777
+ if (shouldStop()) return;
1778
+ createFreshClient();
1779
+ createStreamProcessor("reconnect");
1575
1780
  continue;
1576
1781
  }
1577
1782
  }
@@ -1581,15 +1786,11 @@ async function* createAccountsByOwnerReplay(options) {
1581
1786
  if (cleanupTimer) {
1582
1787
  clearInterval(cleanupTimer);
1583
1788
  }
1584
- const closeIteratorPromise = closeAsyncIterator(activeStreamIterator);
1789
+ const retired = retireActiveStream();
1585
1790
  if (ownsClient) {
1586
1791
  closeIfCloseable(client);
1587
1792
  }
1588
- if (activeStreamProcessor) {
1589
- await Promise.allSettled([closeIteratorPromise, activeStreamProcessor]);
1590
- } else {
1591
- await closeIteratorPromise;
1592
- }
1793
+ await cleanupRetiredStream(retired, "final iterator close", "final processor drain");
1593
1794
  assembler.clear();
1594
1795
  }
1595
1796
  }