@peerbit/shared-log 16.0.7 → 16.0.9

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/src/index.ts CHANGED
@@ -252,6 +252,10 @@ import type {
252
252
  } from "./replication-info-mutation.js";
253
253
  import { ReplicationInfoV2ReceiveCoordinator } from "./replication-info-v2-receive.js";
254
254
  import { ReplicationInfoV2SendCoordinator } from "./replication-info-v2-send.js";
255
+ import {
256
+ type ReplicationStatus,
257
+ classifyReplicationStatus,
258
+ } from "./replication-status.js";
255
259
  import {
256
260
  AbsoluteReplicas,
257
261
  AddedReplicationInfoV2Message,
@@ -416,6 +420,7 @@ export {
416
420
  };
417
421
  export { type CPUUsage, CPUUsageIntervalLag };
418
422
  export * from "./replication.js";
423
+ export * from "./replication-status.js";
419
424
  export type {
420
425
  LogLike,
421
426
  LogResultsIterator,
@@ -1022,6 +1027,11 @@ export type ReplicationLimitsOptions =
1022
1027
  export type DynamicReplicationOptions<R extends "u32" | "u64"> = {
1023
1028
  limits?: {
1024
1029
  interval?: number;
1030
+ /**
1031
+ * Soft byte objective for the adaptive PID controller. This is not a hard
1032
+ * quota: coverage pressure, discrete entries, and delayed pruning can make
1033
+ * actual local storage exceed this value.
1034
+ */
1025
1035
  storage?: number;
1026
1036
  cpu?: number | { max: number; monitor?: CPUUsage };
1027
1037
  };
@@ -1913,6 +1923,14 @@ export type ReplicatorJoinEvent = { publicKey: PublicSignKey };
1913
1923
  export type ReplicatorLeaveEvent = { publicKey: PublicSignKey };
1914
1924
  export type ReplicationChangeEvent = { publicKey: PublicSignKey };
1915
1925
  export type ReplicatorMatureEvent = { publicKey: PublicSignKey };
1926
+ export type ReplicationStatusEvent = ReplicationStatus;
1927
+
1928
+ class ReplicationStatusSnapshotChangedError extends Error {
1929
+ constructor() {
1930
+ super("Replication status changed while it was being measured");
1931
+ this.name = "ReplicationStatusSnapshotChangedError";
1932
+ }
1933
+ }
1916
1934
 
1917
1935
  type LeaderSelectionContext = {
1918
1936
  roleAge: number;
@@ -1927,6 +1945,7 @@ export interface SharedLogEvents extends ProgramEvents {
1927
1945
  "replicator:leave": CustomEvent<ReplicatorLeaveEvent>;
1928
1946
  "replication:change": CustomEvent<ReplicationChangeEvent>;
1929
1947
  "replicator:mature": CustomEvent<ReplicatorMatureEvent>;
1948
+ "replication:status": CustomEvent<ReplicationStatusEvent>;
1930
1949
  }
1931
1950
 
1932
1951
  export type SharedLogRuntimeSnapshot = Readonly<{
@@ -1953,6 +1972,10 @@ export class SharedLog<
1953
1972
  // options
1954
1973
  private _isReplicating!: boolean;
1955
1974
  private _isAdaptiveReplicating!: boolean;
1975
+ private _replicationStatus?: ReplicationStatus;
1976
+ private _replicationStatusReadTail!: Promise<void>;
1977
+ private _replicationStatusRefreshScheduled!: boolean;
1978
+ private _replicationStatusRefreshDirty!: boolean;
1956
1979
 
1957
1980
  private _replicationRangeIndex!: Index<ReplicationRangeIndexable<R>>;
1958
1981
  private _entryCoordinatesIndex!: Index<EntryReplicated<R>>;
@@ -6049,6 +6072,37 @@ export class SharedLog<
6049
6072
  msg: FullReplicationInfoMutation | AddedReplicationInfoMutation,
6050
6073
  ) => void;
6051
6074
  },
6075
+ ) {
6076
+ const wasAdaptiveReplicating = this._isAdaptiveReplicating;
6077
+ const previousStorageObjective = wasAdaptiveReplicating
6078
+ ? this.replicationController?.maxMemoryLimit
6079
+ : undefined;
6080
+ try {
6081
+ return await this.applyReplicationRole(rangeOrEntry, options);
6082
+ } finally {
6083
+ const storageObjective = this._isAdaptiveReplicating
6084
+ ? this.replicationController?.maxMemoryLimit
6085
+ : undefined;
6086
+ if (
6087
+ this._isAdaptiveReplicating !== wasAdaptiveReplicating ||
6088
+ storageObjective !== previousStorageObjective
6089
+ ) {
6090
+ this.scheduleReplicationStatusRefresh();
6091
+ }
6092
+ }
6093
+ }
6094
+
6095
+ private async applyReplicationRole(
6096
+ rangeOrEntry?: ReplicationOptions<R> | Entry<T> | Entry<T>[],
6097
+ options?: {
6098
+ reset?: boolean;
6099
+ checkDuplicates?: boolean;
6100
+ rebalance?: boolean;
6101
+ mergeSegments?: boolean;
6102
+ announce?: (
6103
+ msg: FullReplicationInfoMutation | AddedReplicationInfoMutation,
6104
+ ) => void;
6105
+ },
6052
6106
  ) {
6053
6107
  this.throwIfCheckedPruneRemoveBlocksLocalOperation(
6054
6108
  "replication range mutation",
@@ -6157,15 +6211,31 @@ export class SharedLog<
6157
6211
  }
6158
6212
 
6159
6213
  async unreplicate(rangeOrEntry?: Entry<T> | { id: Uint8Array }[]) {
6214
+ const wasAdaptiveReplicating = this._isAdaptiveReplicating;
6215
+ const previousStorageObjective = wasAdaptiveReplicating
6216
+ ? this.replicationController?.maxMemoryLimit
6217
+ : undefined;
6160
6218
  this.throwIfCheckedPruneRemoveBlocksLocalOperation(
6161
6219
  "replication range mutation",
6162
6220
  );
6163
6221
  const replicationOwnershipLifecycleController =
6164
6222
  this.captureReplicationOwnershipLifecycle();
6165
- return this._unreplicate(
6166
- rangeOrEntry,
6167
- replicationOwnershipLifecycleController,
6168
- );
6223
+ try {
6224
+ return await this._unreplicate(
6225
+ rangeOrEntry,
6226
+ replicationOwnershipLifecycleController,
6227
+ );
6228
+ } finally {
6229
+ const storageObjective = this._isAdaptiveReplicating
6230
+ ? this.replicationController?.maxMemoryLimit
6231
+ : undefined;
6232
+ if (
6233
+ this._isAdaptiveReplicating !== wasAdaptiveReplicating ||
6234
+ storageObjective !== previousStorageObjective
6235
+ ) {
6236
+ this.scheduleReplicationStatusRefresh();
6237
+ }
6238
+ }
6169
6239
  }
6170
6240
 
6171
6241
  private async _unreplicate(
@@ -6535,6 +6605,13 @@ export class SharedLog<
6535
6605
  }
6536
6606
  return;
6537
6607
  }
6608
+ if (deleted.length > 0 || (wasReplicator && !ownerHasRanges)) {
6609
+ // Some committed removals intentionally suppress replication:change,
6610
+ // and liveness cleanup can retire membership without a leave event.
6611
+ // Refresh from the committed post-state instead of relying on either
6612
+ // optional notification path.
6613
+ this.scheduleReplicationStatusRefresh();
6614
+ }
6538
6615
  if (options?.noEvent !== true && deleted.length > 0) {
6539
6616
  const publicKey = toLocalPublicSignKey(key);
6540
6617
  if (publicKey) {
@@ -14326,6 +14403,7 @@ export class SharedLog<
14326
14403
  // _receiveOwnershipRevision and _receiveOwnershipMutationAdmissions
14327
14404
  // all start at 0 on the incoming lifecycle.
14328
14405
  this.startRepairLifecycle();
14406
+ this.resetReplicationStatusLifecycle();
14329
14407
  // Guard: between startRepairLifecycle() and
14330
14408
  // resetSubscriptionChangeCallbackTracking() the fresh lifecycle's
14331
14409
  // membership controller is undefined (legacy exposed the previous
@@ -14509,10 +14587,24 @@ export class SharedLog<
14509
14587
  });
14510
14588
  const invalidateLeaderSelectionContext = () =>
14511
14589
  this.invalidateLeaderSelectionContextCache();
14590
+ const onReplicationStatusInputChange = () =>
14591
+ this.scheduleReplicationStatusRefresh();
14512
14592
  this.events.addEventListener(
14513
14593
  "replication:change",
14514
14594
  invalidateLeaderSelectionContext,
14515
14595
  );
14596
+ this.events.addEventListener(
14597
+ "replication:change",
14598
+ onReplicationStatusInputChange,
14599
+ );
14600
+ this.events.addEventListener(
14601
+ "replicator:join",
14602
+ onReplicationStatusInputChange,
14603
+ );
14604
+ this.events.addEventListener(
14605
+ "replicator:leave",
14606
+ onReplicationStatusInputChange,
14607
+ );
14516
14608
  this.events.addEventListener(
14517
14609
  "replicator:mature",
14518
14610
  invalidateLeaderSelectionContext,
@@ -14522,6 +14614,18 @@ export class SharedLog<
14522
14614
  "replication:change",
14523
14615
  invalidateLeaderSelectionContext,
14524
14616
  );
14617
+ this.events.removeEventListener(
14618
+ "replication:change",
14619
+ onReplicationStatusInputChange,
14620
+ );
14621
+ this.events.removeEventListener(
14622
+ "replicator:join",
14623
+ onReplicationStatusInputChange,
14624
+ );
14625
+ this.events.removeEventListener(
14626
+ "replicator:leave",
14627
+ onReplicationStatusInputChange,
14628
+ );
14525
14629
  this.events.removeEventListener(
14526
14630
  "replicator:mature",
14527
14631
  invalidateLeaderSelectionContext,
@@ -15036,6 +15140,7 @@ export class SharedLog<
15036
15140
  }, RECALCULATE_PARTICIPATION_DEBOUNCE_INTERVAL);
15037
15141
 
15038
15142
  this._instanceLifecycle!.markOpenComplete();
15143
+ this.scheduleReplicationStatusRefresh();
15039
15144
  }
15040
15145
 
15041
15146
  private toNativeReplicationRange(
@@ -15764,6 +15869,7 @@ export class SharedLog<
15764
15869
  const promises: Promise<any>[] = [];
15765
15870
  const iterator = this.replicationIndex.iterate();
15766
15871
  const checkedIsAlive = new Set<string>();
15872
+ const selfHash = this.node.identity.publicKey.hashcode();
15767
15873
 
15768
15874
  while (!iterator.done()) {
15769
15875
  const segments = await iterator.next(1000);
@@ -15775,9 +15881,13 @@ export class SharedLog<
15775
15881
  for (const segment of segments) {
15776
15882
  if (
15777
15883
  checkedIsAlive.has(segment.value.hash) ||
15778
- this.node.identity.publicKey.hashcode() === segment.value.hash
15884
+ selfHash === segment.value.hash
15779
15885
  ) {
15780
- this.uniqueReplicators.add(this.node.identity.publicKey.hashcode());
15886
+ if (!this.uniqueReplicators.has(selfHash)) {
15887
+ this.uniqueReplicators.add(selfHash);
15888
+ this.invalidateLeaderSelectionContextCache();
15889
+ this.scheduleReplicationStatusRefresh();
15890
+ }
15781
15891
  continue;
15782
15892
  }
15783
15893
 
@@ -16076,6 +16186,208 @@ export class SharedLog<
16076
16186
  /* ((await this.log.entryIndex?.getMemoryUsage()) || 0) */ // + (await this.log.blocks.size())
16077
16187
  }
16078
16188
 
16189
+ private resetReplicationStatusLifecycle(): void {
16190
+ this._replicationStatus = undefined;
16191
+ this._replicationStatusReadTail = Promise.resolve();
16192
+ this._replicationStatusRefreshScheduled = false;
16193
+ this._replicationStatusRefreshDirty = false;
16194
+ }
16195
+
16196
+ private isReplicationStatusLifecycleCurrent(
16197
+ lifecycle: InstanceLifecycle | undefined,
16198
+ ): lifecycle is InstanceLifecycle {
16199
+ return (
16200
+ lifecycle != null &&
16201
+ lifecycle === this._instanceLifecycle &&
16202
+ lifecycle.phase() === "active"
16203
+ );
16204
+ }
16205
+
16206
+ private async measureReplicationStatus(
16207
+ lifecycle: InstanceLifecycle,
16208
+ ): Promise<ReplicationStatus> {
16209
+ if (!this.isReplicationStatusLifecycleCurrent(lifecycle)) {
16210
+ throw new ClosedError();
16211
+ }
16212
+
16213
+ const capturedRoleGeneration = lifecycle.roleGeneration;
16214
+ const capturedOwnershipRevision = lifecycle._receiveOwnershipRevision;
16215
+ if (lifecycle._receiveOwnershipMutationAdmissions !== 0) {
16216
+ throw new ReplicationStatusSnapshotChangedError();
16217
+ }
16218
+
16219
+ // Capture every synchronous policy/membership input before yielding. The
16220
+ // generation and ownership checks below reject the whole measurement if a
16221
+ // same-open role or range mutation races either asynchronous metric read.
16222
+ const defaultReplicaTarget = this.replicas.min.getValue(this);
16223
+ const isAdaptiveReplicating = this._isAdaptiveReplicating;
16224
+ const storageObjectiveBytes = isAdaptiveReplicating
16225
+ ? this.replicationController?.maxMemoryLimit
16226
+ : undefined;
16227
+ const activeReplicators = this.uniqueReplicators.size;
16228
+ const [storageUsedBytes, rangeCoverage] = await Promise.all([
16229
+ this.getMemoryUsage(),
16230
+ this.calculateCoverage(),
16231
+ ]);
16232
+ if (!this.isReplicationStatusLifecycleCurrent(lifecycle)) {
16233
+ throw new ClosedError();
16234
+ }
16235
+ const currentStorageObjectiveBytes = this._isAdaptiveReplicating
16236
+ ? this.replicationController?.maxMemoryLimit
16237
+ : undefined;
16238
+ if (
16239
+ !lifecycle.isRoleCurrent(capturedRoleGeneration) ||
16240
+ !this.isReceiveOwnershipSnapshotStable(capturedOwnershipRevision) ||
16241
+ this.replicas.min.getValue(this) !== defaultReplicaTarget ||
16242
+ this._isAdaptiveReplicating !== isAdaptiveReplicating ||
16243
+ currentStorageObjectiveBytes !== storageObjectiveBytes ||
16244
+ this.uniqueReplicators.size !== activeReplicators
16245
+ ) {
16246
+ throw new ReplicationStatusSnapshotChangedError();
16247
+ }
16248
+ const status = classifyReplicationStatus({
16249
+ storageUsedBytes,
16250
+ storageObjectiveBytes,
16251
+ rangeCoverage,
16252
+ defaultReplicaTarget,
16253
+ activeReplicators,
16254
+ });
16255
+ const previous = this._replicationStatus;
16256
+ this._replicationStatus = status;
16257
+ const transitioned =
16258
+ previous == null ||
16259
+ previous.reasons.length !== status.reasons.length ||
16260
+ previous.reasons.some(
16261
+ (reason, index) => reason !== status.reasons[index],
16262
+ );
16263
+ if (transitioned) {
16264
+ this.events.dispatchEvent(
16265
+ new CustomEvent<ReplicationStatusEvent>("replication:status", {
16266
+ detail: status,
16267
+ }),
16268
+ );
16269
+ }
16270
+ return status;
16271
+ }
16272
+
16273
+ /**
16274
+ * Measure local replication health. The snapshot and event are advisory:
16275
+ * they are not persisted, sent to peers, or consumed by replication logic.
16276
+ * Event emission is deduplicated by the ordered reason set, so metric drift
16277
+ * within the same state does not create event traffic. Explicit measurements
16278
+ * work without listeners; automatic refreshes run only while the status event
16279
+ * has at least one listener.
16280
+ */
16281
+ getReplicationStatus(): Promise<ReplicationStatus> {
16282
+ const lifecycle = this._instanceLifecycle;
16283
+ if (!this.isReplicationStatusLifecycleCurrent(lifecycle)) {
16284
+ return Promise.reject(new ClosedError());
16285
+ }
16286
+ const previous = this._replicationStatusReadTail ?? Promise.resolve();
16287
+ const operation = previous
16288
+ .catch(() => {})
16289
+ .then(async () => {
16290
+ let staleError: ReplicationStatusSnapshotChangedError | undefined;
16291
+ for (let attempt = 0; attempt < 2; attempt++) {
16292
+ try {
16293
+ return await this.measureReplicationStatus(lifecycle);
16294
+ } catch (error) {
16295
+ if (!(error instanceof ReplicationStatusSnapshotChangedError)) {
16296
+ throw error;
16297
+ }
16298
+ staleError = error;
16299
+ if (attempt === 0) {
16300
+ const mutationTail = this._replicationRangeMutationTail;
16301
+ await mutationTail.catch(() => {});
16302
+ }
16303
+ }
16304
+ }
16305
+ throw staleError!;
16306
+ });
16307
+ this._replicationStatusReadTail = operation.then(
16308
+ () => {},
16309
+ () => {},
16310
+ );
16311
+ return operation;
16312
+ }
16313
+
16314
+ private scheduleReplicationStatusRefresh(): void {
16315
+ if (this.events.listenerCount("replication:status") === 0) {
16316
+ return;
16317
+ }
16318
+ const lifecycle = this._instanceLifecycle;
16319
+ if (!this.isReplicationStatusLifecycleCurrent(lifecycle)) {
16320
+ return;
16321
+ }
16322
+ if (this._replicationStatusRefreshScheduled) {
16323
+ this._replicationStatusRefreshDirty = true;
16324
+ return;
16325
+ }
16326
+ this._replicationStatusRefreshScheduled = true;
16327
+ queueMicrotask(() => {
16328
+ if (lifecycle !== this._instanceLifecycle) {
16329
+ return;
16330
+ }
16331
+ if (this.events.listenerCount("replication:status") === 0) {
16332
+ this._replicationStatusRefreshScheduled = false;
16333
+ this._replicationStatusRefreshDirty = false;
16334
+ return;
16335
+ }
16336
+ // Calls coalesced before the microtask starts are reflected by this scan.
16337
+ // Calls arriving while it is in flight set the bit again and receive one
16338
+ // follow-up scan for the latest state.
16339
+ this._replicationStatusRefreshDirty = false;
16340
+ if (!this.isReplicationStatusLifecycleCurrent(lifecycle)) {
16341
+ this._replicationStatusRefreshScheduled = false;
16342
+ return;
16343
+ }
16344
+ void this.getReplicationStatus()
16345
+ .catch((error) => {
16346
+ if (error instanceof ReplicationStatusSnapshotChangedError) {
16347
+ this._replicationStatusRefreshDirty = true;
16348
+ return;
16349
+ }
16350
+ if (
16351
+ this.isReplicationStatusLifecycleCurrent(lifecycle) &&
16352
+ !(error instanceof ClosedError) &&
16353
+ !isNotStartedError(error as Error)
16354
+ ) {
16355
+ logger.error(error);
16356
+ }
16357
+ })
16358
+ .then(() => {
16359
+ if (lifecycle !== this._instanceLifecycle) {
16360
+ return;
16361
+ }
16362
+ this._replicationStatusRefreshScheduled = false;
16363
+ if (!this.isReplicationStatusLifecycleCurrent(lifecycle)) {
16364
+ this._replicationStatusRefreshDirty = false;
16365
+ return;
16366
+ }
16367
+ if (this._replicationStatusRefreshDirty) {
16368
+ this._replicationStatusRefreshDirty = false;
16369
+ this.scheduleReplicationStatusRefresh();
16370
+ }
16371
+ });
16372
+ });
16373
+ }
16374
+
16375
+ private scheduleReplicationStatusRefreshForStorage(
16376
+ storageUsedBytes: number,
16377
+ ): void {
16378
+ const objective = this.replicationController?.maxMemoryLimit;
16379
+ if (!this._isAdaptiveReplicating || objective == null) {
16380
+ return;
16381
+ }
16382
+ const exceeded = storageUsedBytes > objective;
16383
+ const wasExceeded = this._replicationStatus?.reasons.includes(
16384
+ "storage-objective-exceeded",
16385
+ );
16386
+ if (wasExceeded == null || wasExceeded !== exceeded) {
16387
+ this.scheduleReplicationStatusRefresh();
16388
+ }
16389
+ }
16390
+
16079
16391
  /** Return a detached snapshot of effective shared-log runtime settings. */
16080
16392
  getRuntimeSnapshot(): SharedLogRuntimeSnapshot {
16081
16393
  const nativeGraph = this.log.entryIndex.properties.nativeGraph;
@@ -16856,6 +17168,7 @@ export class SharedLog<
16856
17168
  private async _close(options?: { preserveDropRetryResources?: boolean }) {
16857
17169
  this.stopRepairLifecycle();
16858
17170
  this._instanceLifecycle?.beginTerminal("internal-close");
17171
+ this.resetReplicationStatusLifecycle();
16859
17172
  const preserveDropRetryResources =
16860
17173
  options?.preserveDropRetryResources === true;
16861
17174
  let firstError: unknown;
@@ -17049,6 +17362,7 @@ export class SharedLog<
17049
17362
  this.preventParentAttachments();
17050
17363
  this.stopRepairLifecycle();
17051
17364
  this._instanceLifecycle?.beginTerminal("close");
17365
+ this.resetReplicationStatusLifecycle();
17052
17366
  this._v2Receive?.clearForClose();
17053
17367
  const replicationRangeTerminalFence =
17054
17368
  this.acquireReplicationRangeMutationTerminalFence();
@@ -17186,6 +17500,7 @@ export class SharedLog<
17186
17500
  this.preventParentAttachments();
17187
17501
  this.stopRepairLifecycle();
17188
17502
  this._instanceLifecycle?.beginTerminal("drop");
17503
+ this.resetReplicationStatusLifecycle();
17189
17504
  this._v2Receive?.clearForClose();
17190
17505
  const replicationRangeTerminalFence =
17191
17506
  this.acquireReplicationRangeMutationTerminalFence();
@@ -25012,6 +25327,7 @@ export class SharedLog<
25012
25327
  const peers = this.replicationIndex;
25013
25328
  const usedMemory = await this.getMemoryUsage();
25014
25329
  if (!isCurrent()) return false;
25330
+ this.scheduleReplicationStatusRefreshForStorage(usedMemory);
25015
25331
  let dynamicRange = await this.getDynamicRange();
25016
25332
  if (!isCurrent()) return false;
25017
25333
 
package/src/like.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import type { Entry } from "@peerbit/log";
8
8
  import type { ShallowEntry } from "@peerbit/log";
9
9
  import type { ReplicationOptions, ReplicationRangeIndexable } from "./index.js";
10
+ import type { ReplicationStatus } from "./replication-status.js";
10
11
 
11
12
  export type LogBlocksLike = {
12
13
  has: (hash: string) => Promise<boolean> | boolean;
@@ -80,5 +81,7 @@ export type SharedLogLike<T = any, R extends "u32" | "u64" = any> = {
80
81
  }) => Promise<number>;
81
82
  getMyReplicationSegments: () => Promise<ReplicationRangeIndexable<R>[]>;
82
83
  getAllReplicationSegments: () => Promise<ReplicationRangeIndexable<R>[]>;
84
+ /** Optional for proxy/legacy implementations that predate local telemetry. */
85
+ getReplicationStatus?: () => Promise<ReplicationStatus>;
83
86
  close: () => Promise<void | boolean>;
84
87
  };
package/src/pid.ts CHANGED
@@ -46,13 +46,13 @@ export class PIDReplicationController {
46
46
  let errorMemory = 0;
47
47
 
48
48
  if (this.maxMemoryLimit != null) {
49
- // Treat the configured storage limit as a ceiling, not the exact control
50
- // target. A small reserve prevents discrete entry sizes and delayed checked
51
- // prunes from repeatedly settling just above the hard budget.
49
+ // Treat the configured storage limit as a soft operating objective, not
50
+ // an exact set point. A small reserve helps discrete entry sizes and delayed
51
+ // checked prunes settle near that objective without repeated overshoot.
52
52
  const effectiveMemoryLimit =
53
53
  this.maxMemoryLimit > 0 ? this.maxMemoryLimit * 0.95 : 0;
54
54
  if (effectiveMemoryLimit <= 0) {
55
- // A zero storage budget can never hold data; drive the factor to zero
55
+ // A zero storage objective can never hold data; drive the factor to zero
56
56
  // instead of leaving the memory term neutral.
57
57
  errorMemory = -currentFactor;
58
58
  } else if (currentFactor > 0 && memoryUsage > 0) {
package/src/ranges.ts CHANGED
@@ -1490,7 +1490,7 @@ export const calculateCoverage = async <R extends "u32" | "u64">(properties: {
1490
1490
  // When points are equal, process a start (delta +1) before an end (delta -1)
1491
1491
  endpoints.sort((a, b) => {
1492
1492
  if (a.point === b.point) return b.delta - a.delta;
1493
- return Number(a.point) - Number(b.point);
1493
+ return a.point < b.point ? -1 : 1;
1494
1494
  });
1495
1495
 
1496
1496
  // If there are no endpoints at all, nothing covers the content range.
@@ -0,0 +1,77 @@
1
+ export const REPLICATION_STATUS_REASONS = [
2
+ "storage-objective-exceeded",
3
+ "range-coverage-underfilled",
4
+ "default-replica-target-unattainable",
5
+ ] as const;
6
+
7
+ export type ReplicationStatusReason =
8
+ (typeof REPLICATION_STATUS_REASONS)[number];
9
+
10
+ export type ReplicationStatusInput = Readonly<{
11
+ storageUsedBytes: number;
12
+ storageObjectiveBytes?: number;
13
+ rangeCoverage: number;
14
+ defaultReplicaTarget: number;
15
+ activeReplicators: number;
16
+ }>;
17
+
18
+ /**
19
+ * Local, advisory replication health. This snapshot is never persisted or
20
+ * exchanged with another peer, and must not be used as placement or pruning
21
+ * authority.
22
+ */
23
+ export type ReplicationStatus = Readonly<{
24
+ healthy: boolean;
25
+ reasons: readonly ReplicationStatusReason[];
26
+ storage: Readonly<{
27
+ usedBytes: number;
28
+ objectiveBytes?: number;
29
+ /** `limits.storage` is a PID objective, not an enforced quota. */
30
+ semantics: "soft-objective";
31
+ }>;
32
+ coverage: Readonly<{
33
+ actual: number;
34
+ target: number;
35
+ }>;
36
+ replicas: Readonly<{
37
+ active: number;
38
+ target: number;
39
+ }>;
40
+ }>;
41
+
42
+ /** Classify a detached measurement without reading or mutating runtime state. */
43
+ export const classifyReplicationStatus = (
44
+ input: ReplicationStatusInput,
45
+ ): ReplicationStatus => {
46
+ const reasons: ReplicationStatusReason[] = [];
47
+ if (
48
+ input.storageObjectiveBytes != null &&
49
+ input.storageUsedBytes > input.storageObjectiveBytes
50
+ ) {
51
+ reasons.push("storage-objective-exceeded");
52
+ }
53
+ if (input.rangeCoverage < input.defaultReplicaTarget) {
54
+ reasons.push("range-coverage-underfilled");
55
+ }
56
+ if (input.activeReplicators < input.defaultReplicaTarget) {
57
+ reasons.push("default-replica-target-unattainable");
58
+ }
59
+
60
+ return Object.freeze({
61
+ healthy: reasons.length === 0,
62
+ reasons: Object.freeze(reasons),
63
+ storage: Object.freeze({
64
+ usedBytes: input.storageUsedBytes,
65
+ objectiveBytes: input.storageObjectiveBytes,
66
+ semantics: "soft-objective" as const,
67
+ }),
68
+ coverage: Object.freeze({
69
+ actual: input.rangeCoverage,
70
+ target: input.defaultReplicaTarget,
71
+ }),
72
+ replicas: Object.freeze({
73
+ active: input.activeReplicators,
74
+ target: input.defaultReplicaTarget,
75
+ }),
76
+ });
77
+ };
@@ -87,11 +87,20 @@ export function createSyncronizer<R extends "u32" | "u64">(
87
87
  end1: bigint | number;
88
88
  start2: bigint | number;
89
89
  end2: bigint | number;
90
+ limit?: number;
90
91
  }) => {
91
92
  const nativeState = props.getNativeState();
93
+ const limit = range.limit;
94
+ if (limit == null) {
95
+ return (
96
+ nativeState?.getEntryHashNumbersInRangeU64?.(range) ??
97
+ nativeState?.getEntryHashNumbersInRange(range)
98
+ );
99
+ }
100
+ const limitedRange = { ...range, limit };
92
101
  return (
93
- nativeState?.getEntryHashNumbersInRangeU64?.(range) ??
94
- nativeState?.getEntryHashNumbersInRange(range)
102
+ nativeState?.getEntryHashNumbersInRangeU64Limited?.(limitedRange) ??
103
+ nativeState?.getEntryHashNumbersInRangeLimited?.(limitedRange)
95
104
  );
96
105
  };
97
106
 
package/src/sync/index.ts CHANGED
@@ -34,6 +34,14 @@ export type SyncOptions<R extends "u32" | "u64"> = {
34
34
  */
35
35
  maxSimpleEntries?: number;
36
36
 
37
+ /**
38
+ * Maximum number of local entries materialized into the range encoder for
39
+ * one incoming rateless sync. Ranges above this limit fall back to bounded
40
+ * simple sync. Defaults to 16,384. This is a per-process entry cap, not an
41
+ * aggregate byte budget across concurrent syncs.
42
+ */
43
+ maxRatelessReceiveRangeEntries?: number;
44
+
37
45
  /**
38
46
  * Maximum number of hash strings in one simple sync message.
39
47
  */
@@ -141,16 +149,18 @@ export type HashSymbolResolver = (
141
149
 
142
150
  export type HashSymbolHashListResolver = (
143
151
  symbols: HashSymbolInput,
144
- ) =>
145
- | Iterable<string>
146
- | undefined
147
- | Promise<Iterable<string> | undefined>;
152
+ ) => Iterable<string> | undefined | Promise<Iterable<string> | undefined>;
148
153
 
149
154
  export type HashSymbolRangeResolver = (range: {
150
155
  start1: bigint | number;
151
156
  end1: bigint | number;
152
157
  start2: bigint | number;
153
158
  end2: bigint | number;
159
+ /**
160
+ * Maximum values to return, including any overflow sentinel. Omitted only by
161
+ * legacy custom synchronizers; built-in receive paths always provide it.
162
+ */
163
+ limit?: number;
154
164
  }) =>
155
165
  | Iterable<bigint | number>
156
166
  | undefined