@harperfast/harper-pro 5.0.17 → 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.
Files changed (31) hide show
  1. package/core/resources/RecordEncoder.ts +15 -12
  2. package/core/resources/RocksTransactionLogStore.ts +47 -22
  3. package/core/resources/Table.ts +98 -32
  4. package/core/resources/auditStore.ts +87 -6
  5. package/core/resources/databases.ts +67 -7
  6. package/dist/cloneNode/cloneNode.js +13 -8
  7. package/dist/cloneNode/cloneNode.js.map +1 -1
  8. package/dist/core/resources/RecordEncoder.js +1 -1
  9. package/dist/core/resources/RecordEncoder.js.map +1 -1
  10. package/dist/core/resources/RocksTransactionLogStore.js +80 -21
  11. package/dist/core/resources/RocksTransactionLogStore.js.map +1 -1
  12. package/dist/core/resources/Table.js +96 -35
  13. package/dist/core/resources/Table.js.map +1 -1
  14. package/dist/core/resources/auditStore.js +83 -6
  15. package/dist/core/resources/auditStore.js.map +1 -1
  16. package/dist/core/resources/databases.js +68 -5
  17. package/dist/core/resources/databases.js.map +1 -1
  18. package/dist/replication/replicationConnection.js +63 -18
  19. package/dist/replication/replicationConnection.js.map +1 -1
  20. package/npm-shrinkwrap.json +2 -2
  21. package/package.json +1 -1
  22. package/replication/replicationConnection.ts +66 -20
  23. package/studio/web/assets/{index-DhLu-DHX.js → index-BIjBsaWw.js} +5 -5
  24. package/studio/web/assets/{index-DhLu-DHX.js.map → index-BIjBsaWw.js.map} +1 -1
  25. package/studio/web/assets/{index.lazy-DBjOisCz.js → index.lazy-DN6bSQzR.js} +2 -2
  26. package/studio/web/assets/{index.lazy-DBjOisCz.js.map → index.lazy-DN6bSQzR.js.map} +1 -1
  27. package/studio/web/assets/{profile-DSL-499E.js → profile-Dyrp-ZIJ.js} +2 -2
  28. package/studio/web/assets/{profile-DSL-499E.js.map → profile-Dyrp-ZIJ.js.map} +1 -1
  29. package/studio/web/assets/{status-BRW5QtzY.js → status-BrfTnnpt.js} +2 -2
  30. package/studio/web/assets/{status-BRW5QtzY.js.map → status-BrfTnnpt.js.map} +1 -1
  31. package/studio/web/index.html +1 -1
@@ -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,
@@ -732,28 +733,25 @@ function makeTable(options) {
732
733
  /**
733
734
  * Set TTL expiration for records in this table. On retrieval, record timestamps are checked for expiration.
734
735
  * This also informs the scheduling for record eviction.
735
- * @param expirationTime Time in seconds until records expire (are stale)
736
- * @param evictionTime Time in seconds until records are evicted (removed)
736
+ * @param opts Time in seconds until records expire, or an options object with `expiration`, `eviction`,
737
+ * and `scanInterval` (all in seconds, all optional). Number form preserves any previously configured
738
+ * eviction/scanInterval; object form replaces all three.
737
739
  */
738
- static setTTLExpiration(expiration) {
739
- // we set up a timer to remove expired entries. we only want the timer/reaper to run in one thread,
740
- // so we use the first one
741
- if (typeof expiration === 'number') {
742
- expirationMs = expiration * 1000;
743
- if (!evictionMs)
744
- evictionMs = 0; // by default, no extra time for eviction
740
+ static setTTLExpiration(opts) {
741
+ if (opts == null || (typeof opts !== 'number' && typeof opts !== 'object'))
742
+ throw new Error('Invalid expiration value type');
743
+ if (typeof opts === 'number') {
744
+ expirationMs = opts * 1000;
745
745
  }
746
- else if (expiration && typeof expiration === 'object') {
747
- // an object with expiration times/options specified
748
- expirationMs = expiration.expiration * 1000;
749
- evictionMs = (expiration.eviction || 0) * 1000;
750
- cleanupInterval = expiration.scanInterval * 1000;
746
+ else {
747
+ // `??` so an explicit 0 is treated as the user's chosen value, not as "missing"
748
+ expirationMs = (opts.expiration ?? 0) * 1000;
749
+ evictionMs = (opts.eviction ?? 0) * 1000;
750
+ cleanupInterval = (opts.scanInterval ?? 0) * 1000;
751
751
  }
752
- else
753
- throw new Error('Invalid expiration value type');
754
752
  if (expirationMs < 0)
755
753
  throw new Error('Expiration can not be negative');
756
- // default to one quarter of the total eviction time, and make sure it fits into a 32-bit signed integer
754
+ // default to one quarter of the total expiration+eviction window
757
755
  cleanupInterval = cleanupInterval || (expirationMs + evictionMs) / 4;
758
756
  scheduleCleanup();
759
757
  }
@@ -2467,9 +2465,19 @@ function makeTable(options) {
2467
2465
  if (!request)
2468
2466
  request = {};
2469
2467
  const getFullRecord = !request.rawEvents;
2470
- 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;
2471
2477
  const thisId = requestTargetToId(request) ?? null; // treat undefined and null as the root
2472
2478
  const subscription = (0, transactionBroadcast_ts_1.addSubscription)(TableResource, thisId, function (id, auditRecord, localTime, beginTxn) {
2479
+ if (dropDuringReplay)
2480
+ return;
2473
2481
  try {
2474
2482
  let type = auditRecord.type;
2475
2483
  let value;
@@ -2514,6 +2522,12 @@ function makeTable(options) {
2514
2522
  logger_ts_1.logger.error?.(error);
2515
2523
  }
2516
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);
2517
2531
  const result = (async () => {
2518
2532
  const isCollection = request.isCollection ?? thisId == null;
2519
2533
  if (isCollection) {
@@ -2527,17 +2541,25 @@ function makeTable(options) {
2527
2541
  if (count > 1000)
2528
2542
  count = 1000; // don't allow too many, we have to hold these in memory
2529
2543
  let startTime = request.startTime;
2544
+ let recordsSinceYield = 0;
2530
2545
  if (isCollection) {
2531
2546
  // a collection should retrieve all descendant ids
2532
2547
  if (startTime) {
2533
2548
  if (count)
2534
2549
  throw new hdbError_js_1.ClientError('startTime and previousCount can not be combined for a table level subscription');
2535
- // 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;
2536
2554
  for (const auditRecord of auditStore.getRange({
2537
2555
  start: startTime,
2538
2556
  exclusiveStart: true,
2539
2557
  snapshot: false, // no need for a snapshot, audits don't change
2540
2558
  })) {
2559
+ if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
2560
+ recordsSinceYield = 0;
2561
+ await rest();
2562
+ }
2541
2563
  if (auditRecord.tableId !== tableId)
2542
2564
  continue;
2543
2565
  const id = auditRecord.recordId;
@@ -2557,15 +2579,34 @@ function makeTable(options) {
2557
2579
  return;
2558
2580
  }
2559
2581
  }
2560
- // TODO: Would like to do this asynchronously, but would need to catch up on anything published during iteration
2561
- //await rest(); // yield for fairness
2562
- 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
2563
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;
2564
2592
  }
2565
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)();
2566
2603
  const history = [];
2567
2604
  // we are collecting the history in reverse order to get the right count, then reversing to send
2568
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
+ }
2569
2610
  try {
2570
2611
  if (auditRecord.tableId !== tableId)
2571
2612
  continue;
@@ -2586,22 +2627,34 @@ function makeTable(options) {
2586
2627
  catch (error) {
2587
2628
  logger_ts_1.logger.error?.('Error getting history entry', auditRecord.localTime, error);
2588
2629
  }
2589
- // TODO: Would like to do this asynchronously, but would need to catch up on anything published during iteration
2590
- //await rest(); // yield for fairness
2591
2630
  }
2592
2631
  for (let i = history.length; i > 0;) {
2593
2632
  send(history[--i]);
2594
2633
  }
2595
- if (history[0])
2596
- subscription.startTime = history[0].localTime; // update so don't double send
2597
2634
  }
2598
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.
2599
2648
  for (const { key: id, value, version, localTime, size } of primaryStore.getRange({
2600
2649
  start: thisId ?? false,
2601
2650
  end: thisId == null ? undefined : [thisId, ordered_binary_1.MAXIMUM_KEY],
2602
2651
  versions: true,
2603
2652
  snapshot: false, // no need for a snapshot, just want the latest data
2604
2653
  })) {
2654
+ if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
2655
+ recordsSinceYield = 0;
2656
+ await rest();
2657
+ }
2605
2658
  if (!value)
2606
2659
  continue;
2607
2660
  send({ id, localTime, value, version, type: 'put', size });
@@ -2632,13 +2685,19 @@ function makeTable(options) {
2632
2685
  }
2633
2686
  logger_ts_1.logger.trace?.('Subscription from', startTime, 'from', thisId, localTime);
2634
2687
  if (startTime < localTime) {
2635
- // 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;
2636
2693
  const history = [];
2637
2694
  let nextTime = localTime;
2638
2695
  let nodeId = entry?.nodeId;
2639
2696
  do {
2640
- //TODO: Would like to do this asynchronously, but we will need to run catch after this to ensure we didn't miss anything
2641
- //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
+ }
2642
2701
  const auditRecord = auditStore.getSync(nextTime, tableId, thisId, nodeId);
2643
2702
  if (auditRecord) {
2644
2703
  if (startTime < nextTime) {
@@ -2664,7 +2723,6 @@ function makeTable(options) {
2664
2723
  for (let i = history.length; i > 0;) {
2665
2724
  send(history[--i]);
2666
2725
  }
2667
- subscription.startTime = localTime; // make sure we don't re-broadcast the current version that we already sent
2668
2726
  }
2669
2727
  if (!request.omitCurrent && entry?.value) {
2670
2728
  // if retain and it exists, send the current value first
@@ -2676,10 +2734,12 @@ function makeTable(options) {
2676
2734
  }
2677
2735
  }
2678
2736
  // now send any queued messages
2679
- for (const event of pendingRealTimeQueue) {
2680
- send(event);
2737
+ if (pendingRealTimeQueue) {
2738
+ for (const event of pendingRealTimeQueue) {
2739
+ send(event);
2740
+ }
2741
+ pendingRealTimeQueue = null;
2681
2742
  }
2682
- pendingRealTimeQueue = null;
2683
2743
  })();
2684
2744
  result.catch((error) => {
2685
2745
  harper_logger_js_1.default.error?.('Error in real-time subscription:', error);
@@ -2691,8 +2751,6 @@ function makeTable(options) {
2691
2751
  }
2692
2752
  subscription.send(event);
2693
2753
  }
2694
- if (request.listener)
2695
- subscription.on('data', request.listener);
2696
2754
  return subscription;
2697
2755
  }
2698
2756
  /**
@@ -4061,6 +4119,9 @@ function makeTable(options) {
4061
4119
  transaction,
4062
4120
  tableToTrack: tableName,
4063
4121
  }, 'put', Boolean(invalidated), auditRecord);
4122
+ // arm the eviction scanner, mirroring the .put() path
4123
+ if (sourceContext.expiresAt)
4124
+ scheduleCleanup();
4064
4125
  }
4065
4126
  else if (existingEntry) {
4066
4127
  logger_ts_1.logger.trace?.(`Deleting resolved record from source with id: ${id}, timestamp: ${new Date(txnTime).toISOString()}`);