@harperfast/harper-pro 5.0.18 → 5.0.19

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.
@@ -93,6 +93,7 @@ exports.EVICTED = 8; // note that 2 is reserved for timestamps
93
93
  const TEST_WRITE_KEY_BUFFER = Buffer.allocUnsafeSlow(8192);
94
94
  const MAX_KEY_BYTES = 1978;
95
95
  const EVENT_HIGH_WATER_MARK = 100;
96
+ const REPLAY_YIELD_INTERVAL = 100; // yield to the event loop every N records during subscription replay
96
97
  const FULL_PERMISSIONS = {
97
98
  read: true,
98
99
  insert: true,
@@ -2464,9 +2465,19 @@ function makeTable(options) {
2464
2465
  if (!request)
2465
2466
  request = {};
2466
2467
  const getFullRecord = !request.rawEvents;
2467
- let pendingRealTimeQueue = []; // while we are servicing a loop for older messages, we have to queue up real-time messages and deliver them in order
2468
+ // While the count, !omitCurrent, and non-collection branches replay older messages, real-time
2469
+ // messages from the listener accumulate here and are drained at the end of the IIFE so they
2470
+ // arrive after the replayed history, in order. The startTime branch sets this to null and
2471
+ // uses dropDuringReplay instead — its snapshot:false cursor picks up the live tail directly.
2472
+ let pendingRealTimeQueue = [];
2473
+ // Set during the startTime audit-log replay. The cursor iterates the audit log forward with
2474
+ // snapshot:false, which catches any commits that land during yield points; dropping in the
2475
+ // listener avoids duplicate delivery.
2476
+ let dropDuringReplay = false;
2468
2477
  const thisId = requestTargetToId(request) ?? null; // treat undefined and null as the root
2469
2478
  const subscription = (0, transactionBroadcast_ts_1.addSubscription)(TableResource, thisId, function (id, auditRecord, localTime, beginTxn) {
2479
+ if (dropDuringReplay)
2480
+ return;
2470
2481
  try {
2471
2482
  let type = auditRecord.type;
2472
2483
  let value;
@@ -2511,6 +2522,12 @@ function makeTable(options) {
2511
2522
  logger_ts_1.logger.error?.(error);
2512
2523
  }
2513
2524
  }, request.startTime || 0, request);
2525
+ // Attach the request.listener BEFORE invoking the IIFE so that sync sends from the
2526
+ // IIFE's prologue go directly to the listener via emit('data') instead of accumulating
2527
+ // in subscription.queue. Without this, the IIFE can fill the queue past
2528
+ // EVENT_HIGH_WATER_MARK and hit waitForDrain before the consumer's listener exists.
2529
+ if (request.listener)
2530
+ subscription.on('data', request.listener);
2514
2531
  const result = (async () => {
2515
2532
  const isCollection = request.isCollection ?? thisId == null;
2516
2533
  if (isCollection) {
@@ -2524,17 +2541,25 @@ function makeTable(options) {
2524
2541
  if (count > 1000)
2525
2542
  count = 1000; // don't allow too many, we have to hold these in memory
2526
2543
  let startTime = request.startTime;
2544
+ let recordsSinceYield = 0;
2527
2545
  if (isCollection) {
2528
2546
  // a collection should retrieve all descendant ids
2529
2547
  if (startTime) {
2530
2548
  if (count)
2531
2549
  throw new hdbError_js_1.ClientError('startTime and previousCount can not be combined for a table level subscription');
2532
- // start time specified, get the audit history for this time range
2550
+ // start time specified, get the audit history for this time range. We drop real-time
2551
+ // messages during this loop because the snapshot:false cursor will pick them up itself.
2552
+ pendingRealTimeQueue = null;
2553
+ dropDuringReplay = true;
2533
2554
  for (const auditRecord of auditStore.getRange({
2534
2555
  start: startTime,
2535
2556
  exclusiveStart: true,
2536
2557
  snapshot: false, // no need for a snapshot, audits don't change
2537
2558
  })) {
2559
+ if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
2560
+ recordsSinceYield = 0;
2561
+ await rest();
2562
+ }
2538
2563
  if (auditRecord.tableId !== tableId)
2539
2564
  continue;
2540
2565
  const id = auditRecord.recordId;
@@ -2554,15 +2579,34 @@ function makeTable(options) {
2554
2579
  return;
2555
2580
  }
2556
2581
  }
2557
- // TODO: Would like to do this asynchronously, but would need to catch up on anything published during iteration
2558
- //await rest(); // yield for fairness
2559
- subscription.startTime = auditRecord.localTime; // update so we don't double send
2582
+ subscription.startTime = auditRecord.localTime ?? auditRecord.version; // update so we don't double send
2560
2583
  }
2584
+ // No catch-up sweep needed. With snapshot:false (lmdb), notifyFromTransactionData
2585
+ // calls resetReadTxn before iterating, which bumps renewId; on the cursor's next
2586
+ // .next() it renews to a fresh txn whose snapshot is at least as recent. With
2587
+ // rocksdb, the audit-log iterator re-reads `_lastCommittedPosition` on each next()
2588
+ // (live tail). Either way, at loop exit subscription.startTime is at or past
2589
+ // lastTxnTime, and the gate in notifyFromTransactionData handles the handoff
2590
+ // once dropDuringReplay flips back.
2591
+ dropDuringReplay = false;
2561
2592
  }
2562
2593
  else if (count) {
2594
+ // Raise the listener's gate up front so that any in-flight 'committed' callbacks
2595
+ // for records the cursor will capture in `history` get gated out of
2596
+ // pendingRealTimeQueue rather than queued and re-emitted as duplicates after
2597
+ // history is sent. getNextMonotonicTime() returns a strictly-greater value than
2598
+ // any audit record's localTime issued so far — it's the same source Harper uses
2599
+ // to assign localTimes — so this gates exactly the records the cursor's
2600
+ // snapshot:true view can see. Anything committed strictly after this point will
2601
+ // pass the gate and reach the queue.
2602
+ subscription.startTime = (0, commonUtility_js_1.getNextMonotonicTime)();
2563
2603
  const history = [];
2564
2604
  // we are collecting the history in reverse order to get the right count, then reversing to send
2565
2605
  for (const auditRecord of auditStore.getRange({ start: 'z', end: false, reverse: true })) {
2606
+ if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
2607
+ recordsSinceYield = 0;
2608
+ await rest();
2609
+ }
2566
2610
  try {
2567
2611
  if (auditRecord.tableId !== tableId)
2568
2612
  continue;
@@ -2583,22 +2627,34 @@ function makeTable(options) {
2583
2627
  catch (error) {
2584
2628
  logger_ts_1.logger.error?.('Error getting history entry', auditRecord.localTime, error);
2585
2629
  }
2586
- // TODO: Would like to do this asynchronously, but would need to catch up on anything published during iteration
2587
- //await rest(); // yield for fairness
2588
2630
  }
2589
2631
  for (let i = history.length; i > 0;) {
2590
2632
  send(history[--i]);
2591
2633
  }
2592
- if (history[0])
2593
- subscription.startTime = history[0].localTime; // update so don't double send
2594
2634
  }
2595
2635
  else if (!request.omitCurrent) {
2636
+ // Raise the listener's gate up front so that any in-flight 'committed' callbacks
2637
+ // for pre-subscribe commits (which haven't yet advanced lastTxnTime when subscribe
2638
+ // is called) get gated out of the queue. Otherwise the listener fires for them
2639
+ // during cursor yields and emits stale events the cursor either covered (current
2640
+ // state) or correctly skipped (e.g., deletes via `if (!value) continue`).
2641
+ // getNextMonotonicTime() is the same source Harper uses to assign audit record
2642
+ // localTimes, so the gate cuts at a precise instant in the same time domain.
2643
+ subscription.startTime = (0, commonUtility_js_1.getNextMonotonicTime)();
2644
+ // Retained-message semantics: subscriber may legitimately receive a record twice
2645
+ // if a post-subscribe write hits a key the cursor also visits. This is
2646
+ // idempotent for "current state then live updates" — both deliveries land at
2647
+ // the same final state. We don't dedupe.
2596
2648
  for (const { key: id, value, version, localTime, size } of primaryStore.getRange({
2597
2649
  start: thisId ?? false,
2598
2650
  end: thisId == null ? undefined : [thisId, ordered_binary_1.MAXIMUM_KEY],
2599
2651
  versions: true,
2600
2652
  snapshot: false, // no need for a snapshot, just want the latest data
2601
2653
  })) {
2654
+ if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
2655
+ recordsSinceYield = 0;
2656
+ await rest();
2657
+ }
2602
2658
  if (!value)
2603
2659
  continue;
2604
2660
  send({ id, localTime, value, version, type: 'put', size });
@@ -2629,13 +2685,19 @@ function makeTable(options) {
2629
2685
  }
2630
2686
  logger_ts_1.logger.trace?.('Subscription from', startTime, 'from', thisId, localTime);
2631
2687
  if (startTime < localTime) {
2632
- // start time specified, get the audit history for this record
2688
+ // start time specified, get the audit history for this record. Set startTime up
2689
+ // front so the listener gate skips any in-flight 'committed' for this version
2690
+ // during the yields below — otherwise that event would be queued and drained as a
2691
+ // duplicate of the entry send.
2692
+ subscription.startTime = localTime ?? entry?.version;
2633
2693
  const history = [];
2634
2694
  let nextTime = localTime;
2635
2695
  let nodeId = entry?.nodeId;
2636
2696
  do {
2637
- //TODO: Would like to do this asynchronously, but we will need to run catch after this to ensure we didn't miss anything
2638
- //await auditStore.prefetch([key]); // do it asynchronously for better fairness/concurrency and avoid page faults
2697
+ if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
2698
+ recordsSinceYield = 0;
2699
+ await rest();
2700
+ }
2639
2701
  const auditRecord = auditStore.getSync(nextTime, tableId, thisId, nodeId);
2640
2702
  if (auditRecord) {
2641
2703
  if (startTime < nextTime) {
@@ -2661,7 +2723,6 @@ function makeTable(options) {
2661
2723
  for (let i = history.length; i > 0;) {
2662
2724
  send(history[--i]);
2663
2725
  }
2664
- subscription.startTime = localTime; // make sure we don't re-broadcast the current version that we already sent
2665
2726
  }
2666
2727
  if (!request.omitCurrent && entry?.value) {
2667
2728
  // if retain and it exists, send the current value first
@@ -2673,10 +2734,12 @@ function makeTable(options) {
2673
2734
  }
2674
2735
  }
2675
2736
  // now send any queued messages
2676
- for (const event of pendingRealTimeQueue) {
2677
- send(event);
2737
+ if (pendingRealTimeQueue) {
2738
+ for (const event of pendingRealTimeQueue) {
2739
+ send(event);
2740
+ }
2741
+ pendingRealTimeQueue = null;
2678
2742
  }
2679
- pendingRealTimeQueue = null;
2680
2743
  })();
2681
2744
  result.catch((error) => {
2682
2745
  harper_logger_js_1.default.error?.('Error in real-time subscription:', error);
@@ -2688,8 +2751,6 @@ function makeTable(options) {
2688
2751
  }
2689
2752
  subscription.send(event);
2690
2753
  }
2691
- if (request.listener)
2692
- subscription.on('data', request.listener);
2693
2754
  return subscription;
2694
2755
  }
2695
2756
  /**