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