@harperfast/harper-pro 5.0.25 → 5.0.26

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.
@@ -94,6 +94,11 @@ const TEST_WRITE_KEY_BUFFER = Buffer.allocUnsafeSlow(8192);
94
94
  const MAX_KEY_BYTES = 1978;
95
95
  const EVENT_HIGH_WATER_MARK = 100;
96
96
  const REPLAY_YIELD_INTERVAL = 100; // yield to the event loop every N records during subscription replay
97
+ // Cap for the out-of-order write reconciliation audit-chain walk in commit(). A pathologically deep
98
+ // audit history (e.g. a replication full-copy of a large-history database) would otherwise walk and
99
+ // buffer the entire backward chain per record, synchronously, on every worker — pinning the JS heap
100
+ // until the worker OOMs (issue #1114). Beyond this depth we fall back to a bounded reconciliation.
101
+ const MAX_OUT_OF_ORDER_AUDIT_DEPTH = 1000;
97
102
  const FULL_PERMISSIONS = {
98
103
  read: true,
99
104
  insert: true,
@@ -1620,8 +1625,18 @@ function makeTable(options) {
1620
1625
  }
1621
1626
  let addedAuditRef = false;
1622
1627
  let nextRef;
1628
+ let walkSteps = 0;
1629
+ let auditWalkCapped = false;
1623
1630
  do {
1624
1631
  while (localTime > txnTime || (auditedVersion >= txnTime && localTime > 0)) {
1632
+ // Bound the walk only for RocksDB, where the OOM was observed (issue #1114): each step
1633
+ // is a transaction-log range scan + msgpackr decode, and the per-node logs can be huge.
1634
+ // LMDB audit entries are keyed by local audit time (not version), so the duplicate
1635
+ // shortcut below would not apply — keep its exact, unbounded reconciliation.
1636
+ if (isRocksDB && ++walkSteps > MAX_OUT_OF_ORDER_AUDIT_DEPTH) {
1637
+ auditWalkCapped = true;
1638
+ break;
1639
+ }
1625
1640
  const auditRecord = auditStore.get(localTime, tableId, id, nodeId);
1626
1641
  if (!auditRecord)
1627
1642
  break;
@@ -1643,8 +1658,11 @@ function makeTable(options) {
1643
1658
  }
1644
1659
  if (auditRecord.type === 'patch') {
1645
1660
  logger_ts_1.logger.debug?.('out of order patch will be applied', id, auditRecord);
1646
- // record patches so we can reply in order
1647
- succeedingUpdates.push(auditRecord);
1661
+ // Materialize the patch value now and keep only { version, value } rather than the
1662
+ // audit record itself, so its backing transaction-log buffer and decoders can be
1663
+ // reclaimed immediately. Only these two fields are needed for the ordered fold below;
1664
+ // retaining the full records is what pins the heap on a deep chain (issue #1114).
1665
+ succeedingUpdates.push({ version: auditedVersion, value: auditRecord.getValue(primaryStore) });
1648
1666
  auditRecordToStore = recordUpdate; // use the original update for the audit record
1649
1667
  }
1650
1668
  else if (auditRecord.type === 'put' || auditRecord.type === 'delete') {
@@ -1676,6 +1694,8 @@ function makeTable(options) {
1676
1694
  nodeId = auditRecord.previousNodeId;
1677
1695
  }
1678
1696
  // Check if we need to scan additional audit refs from this record
1697
+ if (auditWalkCapped)
1698
+ break;
1679
1699
  nextRef = auditRefsToVisit.shift();
1680
1700
  if (nextRef) {
1681
1701
  localTime = auditedVersion = nextRef.localTime;
@@ -1683,14 +1703,40 @@ function makeTable(options) {
1683
1703
  logger_ts_1.logger.debug?.('Following additional audit ref to continue scanning', { localTime, nodeId });
1684
1704
  }
1685
1705
  } while (nextRef);
1686
- if (!localTime) {
1706
+ if (!localTime && !auditWalkCapped) {
1687
1707
  // if we reached the end of the audit trail, we can just apply the update
1688
1708
  logger_ts_1.logger.debug?.('No further audit history, applying incremental updates based on available history', id, 'existing version preserved', existingEntry);
1689
1709
  }
1710
+ if (auditWalkCapped) {
1711
+ // The out-of-order audit chain exceeded MAX_OUT_OF_ORDER_AUDIT_DEPTH (a pathologically deep
1712
+ // history, seen during a replication full-copy of a large-history database — issue #1114).
1713
+ // Walking and buffering the whole chain per record OOMs the worker, so we stopped at the cap
1714
+ // and reconcile against only the most recent MAX_OUT_OF_ORDER_AUDIT_DEPTH updates (the fold
1715
+ // below). That is an approximation for histories deeper than the cap — updates older than the
1716
+ // retained window are not layered in — but the authoritative full-copy record restores exact
1717
+ // convergence. Because we stopped before reaching txnTime, the inline duplicate detection in
1718
+ // the walk never ran; full-copy audit-replay re-delivers writes, and re-applying one would
1719
+ // double-apply its commutative ops, so rule that out here with a single O(1) lookup at txnTime
1720
+ // (RocksDB audit logs are keyed by version, and the cap is RocksDB-only).
1721
+ logger_ts_1.logger.warn?.('Out-of-order audit reconciliation exceeded depth cap; reconciling against most recent updates only', {
1722
+ table: tableName,
1723
+ id,
1724
+ depth: walkSteps,
1725
+ });
1726
+ const duplicate = auditStore.get(txnTime, tableId, id, options?.nodeId);
1727
+ if (duplicate &&
1728
+ duplicate.version === txnTime &&
1729
+ precedesExistingVersion(txnTime, { version: txnTime, localTime: txnTime, key: id, nodeId: duplicate.nodeId }, options?.nodeId) === 0) {
1730
+ write.skipped = true;
1731
+ return; // duplicate write already applied
1732
+ }
1733
+ }
1734
+ // Fold the retained succeeding updates (the full chain, or — when capped — the most recent
1735
+ // window) onto this older write so newer fields win; for a capped walk this layers in only
1736
+ // what we collected before the cap.
1690
1737
  succeedingUpdates.sort((a, b) => a.version - b.version); // order the patches
1691
- for (const auditRecord of succeedingUpdates) {
1692
- const newerUpdate = auditRecord.getValue(primaryStore);
1693
- logger_ts_1.logger.debug?.('Rebuilding update with future patch:', new Date(auditRecord.version), newerUpdate, auditRecord);
1738
+ for (const { version: patchVersion, value: newerUpdate } of succeedingUpdates) {
1739
+ logger_ts_1.logger.debug?.('Rebuilding update with future patch:', new Date(patchVersion), newerUpdate);
1694
1740
  incrementalUpdateToApply = (0, crdt_ts_1.rebuildUpdateBefore)(incrementalUpdateToApply ?? recordUpdate, newerUpdate, fullUpdate);
1695
1741
  if (!incrementalUpdateToApply)
1696
1742
  return writeCommit(false); // if all changes are overwritten, nothing left to do