p2p-fret 1.1.0 → 1.2.0
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/src/index.d.ts +23 -0
- package/dist/src/service/fret-service.d.ts +20 -3
- package/dist/src/service/fret-service.js +98 -26
- package/dist/src/service/libp2p-fret-service.d.ts +1 -0
- package/dist/src/service/libp2p-fret-service.js +4 -1
- package/dist/src/store/digitree-store.d.ts +25 -0
- package/dist/src/store/digitree-store.js +13 -1
- package/dist/src/store/relevance.js +15 -9
- package/dist/src/store/tenure.d.ts +72 -0
- package/dist/src/store/tenure.js +91 -0
- package/package.json +1 -1
- package/src/index.ts +23 -0
- package/src/service/fret-service.ts +96 -24
- package/src/service/libp2p-fret-service.ts +5 -1
- package/src/store/digitree-store.ts +739 -699
- package/src/store/relevance.ts +16 -9
- package/src/store/tenure.ts +100 -0
package/dist/src/index.d.ts
CHANGED
|
@@ -48,6 +48,22 @@ export interface FretConfig {
|
|
|
48
48
|
* Must be a positive finite number; the constructor throws otherwise.
|
|
49
49
|
*/
|
|
50
50
|
maxPassiveIntervalMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* The longest grace, in milliseconds, a peer this node has watched stay up for a long time is
|
|
53
|
+
* given to come back before it is marked `dead` (default 300 000 ms — 5 minutes).
|
|
54
|
+
*
|
|
55
|
+
* A peer is normally marked dead after `deadAfterFailures` spaced failed contacts — a few
|
|
56
|
+
* seconds. A peer with long *proven tenure* (how long this node itself has watched it stay up)
|
|
57
|
+
* is instead marked dead only once its failure run has also lasted `tenure / 12`, capped here:
|
|
58
|
+
* nothing below 10 minutes of tenure, 50 s at 10 minutes, 2.5 minutes at 30, the full cap from
|
|
59
|
+
* an hour on. So a long-running server down for a few minutes of maintenance keeps its place in
|
|
60
|
+
* the ring rather than having the ring re-form around it twice. While in grace the peer is still
|
|
61
|
+
* a live member — routing steers around it by its probe backoff, and cohorts keep it as a
|
|
62
|
+
* member that is merely unavailable. `0` disables grace for every peer. Must be a finite number
|
|
63
|
+
* between 0 and 15 minutes (the tenure gap tolerance — a longer grace could keep a peer "alive"
|
|
64
|
+
* across a silence long enough to restart its session); the constructor throws otherwise.
|
|
65
|
+
*/
|
|
66
|
+
maxDeadGraceMs?: number;
|
|
51
67
|
/**
|
|
52
68
|
* This node's libp2p private key — the key `node.peerId` was derived from.
|
|
53
69
|
*
|
|
@@ -174,6 +190,13 @@ export interface FretService {
|
|
|
174
190
|
id: string;
|
|
175
191
|
metadata?: Record<string, unknown>;
|
|
176
192
|
}>;
|
|
193
|
+
/**
|
|
194
|
+
* How long, in ms, this node has itself watched `peerId` stay continuously up — measured from
|
|
195
|
+
* proof of life only, never gossiped (see *Tenure* in docs/fret.md). 0 for a peer the table
|
|
196
|
+
* holds but has never proven alive; `undefined` for one it does not hold. Cohort order does not
|
|
197
|
+
* use it — a higher layer may, e.g. to choose which cohort members to ask first.
|
|
198
|
+
*/
|
|
199
|
+
getPeerTenure(peerId: string): number | undefined;
|
|
177
200
|
reportNetworkSize(estimate: number, confidence: number, source?: string): void;
|
|
178
201
|
getNetworkSizeEstimate(): {
|
|
179
202
|
size_estimate: number;
|
|
@@ -389,7 +389,7 @@ export declare class FretService implements IFretService, Startable {
|
|
|
389
389
|
*
|
|
390
390
|
* **Scoring never creates** — a peer we hold no entry for is not scored, and the call returns
|
|
391
391
|
* before the `selfCoord()` await. See {@link applyTouch} for why, and for where creation does
|
|
392
|
-
* belong. The
|
|
392
|
+
* belong. The `applyMembershipSignal` / `noteProofOfLife` land inside that guard and
|
|
393
393
|
* so stop running for a missing peer, which loses nothing: both already no-op on one.
|
|
394
394
|
*
|
|
395
395
|
* `latencyMs` is optional and must be supplied **only** when the caller timed a round trip
|
|
@@ -428,7 +428,16 @@ export declare class FretService implements IFretService, Startable {
|
|
|
428
428
|
*/
|
|
429
429
|
private applyContactFailure;
|
|
430
430
|
/**
|
|
431
|
-
* Count one failed contact against `id`, marking it `dead` once the run completes
|
|
431
|
+
* Count one failed contact against `id`, marking it `dead` once the run completes — and, for a
|
|
432
|
+
* peer with long proven tenure, has also lasted that peer's grace.
|
|
433
|
+
*
|
|
434
|
+
* **The grace** (`deadGraceMs` in `store/tenure.ts`, capped by `maxDeadGraceMs`) is time, not
|
|
435
|
+
* strikes: strikes arrive at the pass rate, so a larger count would stretch seconds, never the
|
|
436
|
+
* minutes a server's maintenance takes. A peer under 10 minutes of tenure has none and is judged
|
|
437
|
+
* exactly as before. Tenure is frozen during the outage (no proof of life arrives), so the grace
|
|
438
|
+
* is stable across the run without being snapshotted at its start. A peer held in grace is still
|
|
439
|
+
* a live member: routing steers around it by its probe backoff, and cohorts keep it as a member
|
|
440
|
+
* that is merely unavailable.
|
|
432
441
|
*
|
|
433
442
|
* **Synchronous by construction.** `applySuccess` / `applyFailure` read an entry, await the
|
|
434
443
|
* self coordinate, then write a value derived before the await — so two concurrent chains
|
|
@@ -446,7 +455,7 @@ export declare class FretService implements IFretService, Startable {
|
|
|
446
455
|
private applyContactStrike;
|
|
447
456
|
/**
|
|
448
457
|
* Record that `id` is demonstrably alive: clear its contact-failure run and, if it was marked
|
|
449
|
-
* `dead`, restore it to a live state.
|
|
458
|
+
* `dead`, restore it to a live state — and advance its session tenure (`store/tenure.ts`).
|
|
450
459
|
*
|
|
451
460
|
* Called from every site that proves liveness: the RPC sites through {@link noteRpcEvidence},
|
|
452
461
|
* and a fresh transport connection (`peer:connect`) directly. The counter reset is the
|
|
@@ -456,8 +465,15 @@ export declare class FretService implements IFretService, Startable {
|
|
|
456
465
|
*
|
|
457
466
|
* Relevance is deliberately *not* reset to a baseline. `scoreSuccess` already up-ranks on
|
|
458
467
|
* success, and wiping the health counters would erase the record of a peer that flaps.
|
|
468
|
+
*
|
|
469
|
+
* This is the **only** place tenure advances, so it counts proof of life and nothing else:
|
|
470
|
+
* hearsay (`noteDiscovered`, address hints, the peerStore listeners) never reaches it. The
|
|
471
|
+
* stamp rides the same write as the liveness patch, and a stamp alone is **not** a change —
|
|
472
|
+
* otherwise every RPC from a settled neighbour would snap the idle backoff back to its base.
|
|
459
473
|
*/
|
|
460
474
|
private noteProofOfLife;
|
|
475
|
+
/** What proof of life changes about `e`'s liveness bookkeeping — empty when already settled. */
|
|
476
|
+
private livenessPatch;
|
|
461
477
|
/**
|
|
462
478
|
* The remote *answered on this network's namespaced protocol* — a framed reply came back,
|
|
463
479
|
* whatever it said: `ok: false`, `busy`, or bytes that would not decode.
|
|
@@ -1712,6 +1728,7 @@ export declare class FretService implements IFretService, Startable {
|
|
|
1712
1728
|
id: string;
|
|
1713
1729
|
metadata?: Record<string, unknown>;
|
|
1714
1730
|
}>;
|
|
1731
|
+
getPeerTenure(peerId: string): number | undefined;
|
|
1715
1732
|
exportTable(): SerializedTable;
|
|
1716
1733
|
importTable(table: SerializedTable): Promise<number>;
|
|
1717
1734
|
/**
|
|
@@ -28,6 +28,7 @@ import { NearExchange, snapshotDigest } from './near-exchange.js';
|
|
|
28
28
|
import { IdleBackoff } from './idle-backoff.js';
|
|
29
29
|
import { ringNeighborsBothSides } from '../ring/ring-walk.js';
|
|
30
30
|
import { createSparsityModel, normalizedLogDistance, sparsityBonus, touch as scoreTouch, recordSuccess as scoreSuccess, recordFailure as scoreFailure, initialRelevance, } from '../store/relevance.js';
|
|
31
|
+
import { deadGraceMs, noteProven, provenTenureMs, TENURE_GAP_TOLERANCE_MS } from '../store/tenure.js';
|
|
31
32
|
import { createLogger } from '../logger.js';
|
|
32
33
|
const log = createLogger('service:fret');
|
|
33
34
|
function isPlainObject(value) {
|
|
@@ -118,6 +119,20 @@ function requirePositiveMs(value, fallback, name) {
|
|
|
118
119
|
}
|
|
119
120
|
return value;
|
|
120
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The configured dead-verdict grace ceiling (`FretConfig.maxDeadGraceMs`): a finite number of ms
|
|
124
|
+
* from 0 (the off switch) up to the tenure gap tolerance; absent takes `fallback`. Past the
|
|
125
|
+
* tolerance a grace could hold a peer "not dead" across a silence long enough that its session
|
|
126
|
+
* restarts on return — a contradiction, so it fails here rather than being clamped.
|
|
127
|
+
*/
|
|
128
|
+
function requireDeadGraceMs(value, fallback) {
|
|
129
|
+
if (value === undefined)
|
|
130
|
+
return fallback;
|
|
131
|
+
if (!Number.isFinite(value) || value < 0 || value > TENURE_GAP_TOLERANCE_MS) {
|
|
132
|
+
throw new Error(`FretService: maxDeadGraceMs must be a finite number of milliseconds from 0 to ${TENURE_GAP_TOLERANCE_MS}, got ${String(value)}`);
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
121
136
|
/**
|
|
122
137
|
* The peer ids named by configured bootstrap entries: a multiaddr's `p2p` component, otherwise
|
|
123
138
|
* the entry itself as a bare peer id. An entry that yields no parseable id is logged and skipped
|
|
@@ -485,6 +500,13 @@ export class FretService {
|
|
|
485
500
|
* sent). A redial cancelled mid-flight by `stop()` counts in neither verdict.
|
|
486
501
|
*/
|
|
487
502
|
redials: { started: 0, connected: 0, failed: 0 },
|
|
503
|
+
/**
|
|
504
|
+
* Contact strikes that completed a failure run against a live peer but did not mark it
|
|
505
|
+
* `dead`, because the run had not yet lasted the peer's tenure-earned grace (see
|
|
506
|
+
* `applyContactStrike`). One per such strike, so a long-tenured peer that stays silent
|
|
507
|
+
* through its grace counts once per pass until the verdict lands.
|
|
508
|
+
*/
|
|
509
|
+
deadGraceHeld: 0,
|
|
488
510
|
/**
|
|
489
511
|
* The stabilization loop's idle backoff, as of the last completed pass (see `IdleBackoff`):
|
|
490
512
|
* `delayMs` is the passive delay the loop armed after it — the base while things change,
|
|
@@ -564,6 +586,7 @@ export class FretService {
|
|
|
564
586
|
// Validated like `dialTimeoutMs`: a host that set it meant a number. A value at or
|
|
565
587
|
// below the base interval is the off switch — `IdleBackoff` clamps the cap to the base.
|
|
566
588
|
maxPassiveIntervalMs: requirePositiveMs(cfg?.maxPassiveIntervalMs, 60_000, 'maxPassiveIntervalMs'),
|
|
589
|
+
maxDeadGraceMs: requireDeadGraceMs(cfg?.maxDeadGraceMs, 300_000),
|
|
567
590
|
};
|
|
568
591
|
this.idle = new IdleBackoff({ baseMs: FretService.PASSIVE_INTERVAL_MS, capMs: this.cfg.maxPassiveIntervalMs });
|
|
569
592
|
// A key that does not derive this node's peer id would seal records no receiver accepts
|
|
@@ -771,7 +794,7 @@ export class FretService {
|
|
|
771
794
|
*
|
|
772
795
|
* **Scoring never creates** — a peer we hold no entry for is not scored, and the call returns
|
|
773
796
|
* before the `selfCoord()` await. See {@link applyTouch} for why, and for where creation does
|
|
774
|
-
* belong. The
|
|
797
|
+
* belong. The `applyMembershipSignal` / `noteProofOfLife` land inside that guard and
|
|
775
798
|
* so stop running for a missing peer, which loses nothing: both already no-op on one.
|
|
776
799
|
*
|
|
777
800
|
* `latencyMs` is optional and must be supplied **only** when the caller timed a round trip
|
|
@@ -789,6 +812,16 @@ export class FretService {
|
|
|
789
812
|
* inside the store instead of patching a value derived outside it.
|
|
790
813
|
*/
|
|
791
814
|
async applySuccess(id, latencyMs) {
|
|
815
|
+
if (!this.store.getById(id))
|
|
816
|
+
return;
|
|
817
|
+
// Every applySuccess call in this service follows a completed RPC over this
|
|
818
|
+
// network's namespaced protocol (ping or maybeAct), which proves the peer
|
|
819
|
+
// serves this network → confirm membership for free off normal traffic…
|
|
820
|
+
this.applyMembershipSignal(id, 'rpc-success');
|
|
821
|
+
// …and proves it is reachable, which clears any contact-failure run and resurrects it if
|
|
822
|
+
// a previous run had marked it dead. Before the score, so the score reads the tenure this
|
|
823
|
+
// very proof extended or restarted — not a session from before a long absence.
|
|
824
|
+
this.noteRpcEvidence(id);
|
|
792
825
|
const entry = this.store.getById(id);
|
|
793
826
|
if (!entry)
|
|
794
827
|
return;
|
|
@@ -806,13 +839,6 @@ export class FretService {
|
|
|
806
839
|
// has nothing to say about.
|
|
807
840
|
...(latencyMs === undefined ? {} : { avgLatencyMs: next.avgLatencyMs }),
|
|
808
841
|
});
|
|
809
|
-
// Every applySuccess call in this service follows a completed RPC over this
|
|
810
|
-
// network's namespaced protocol (ping or maybeAct), which proves the peer
|
|
811
|
-
// serves this network → confirm membership for free off normal traffic.
|
|
812
|
-
this.applyMembershipSignal(id, 'rpc-success');
|
|
813
|
-
// …and proves it is reachable, which clears any contact-failure run and resurrects it if
|
|
814
|
-
// a previous run had marked it dead.
|
|
815
|
-
this.noteRpcEvidence(id);
|
|
816
842
|
}
|
|
817
843
|
/**
|
|
818
844
|
* Record a failed interaction with `id`.
|
|
@@ -851,7 +877,16 @@ export class FretService {
|
|
|
851
877
|
this.applyContactStrike(id);
|
|
852
878
|
}
|
|
853
879
|
/**
|
|
854
|
-
* Count one failed contact against `id`, marking it `dead` once the run completes
|
|
880
|
+
* Count one failed contact against `id`, marking it `dead` once the run completes — and, for a
|
|
881
|
+
* peer with long proven tenure, has also lasted that peer's grace.
|
|
882
|
+
*
|
|
883
|
+
* **The grace** (`deadGraceMs` in `store/tenure.ts`, capped by `maxDeadGraceMs`) is time, not
|
|
884
|
+
* strikes: strikes arrive at the pass rate, so a larger count would stretch seconds, never the
|
|
885
|
+
* minutes a server's maintenance takes. A peer under 10 minutes of tenure has none and is judged
|
|
886
|
+
* exactly as before. Tenure is frozen during the outage (no proof of life arrives), so the grace
|
|
887
|
+
* is stable across the run without being snapshotted at its start. A peer held in grace is still
|
|
888
|
+
* a live member: routing steers around it by its probe backoff, and cohorts keep it as a member
|
|
889
|
+
* that is merely unavailable.
|
|
855
890
|
*
|
|
856
891
|
* **Synchronous by construction.** `applySuccess` / `applyFailure` read an entry, await the
|
|
857
892
|
* self coordinate, then write a value derived before the await — so two concurrent chains
|
|
@@ -884,20 +919,30 @@ export class FretService {
|
|
|
884
919
|
// Clamped at the threshold so the counter stays bounded for a peer we keep re-probing;
|
|
885
920
|
// a peer already dead simply stays dead.
|
|
886
921
|
const failures = Math.min(e.contactFailures + 1, threshold);
|
|
887
|
-
|
|
888
|
-
|
|
922
|
+
// A run carrying no start stamp (counter written outside this method) reads as long over, so
|
|
923
|
+
// its verdict falls back to the strike count alone — today's rule, never a stuck grace.
|
|
924
|
+
const runStart = e.contactFailures === 0 ? now : e.contactRunStartedAt;
|
|
925
|
+
const completed = failures >= threshold && e.state !== 'dead';
|
|
926
|
+
const toDead = completed && now - runStart >= deadGraceMs(provenTenureMs(e), this.cfg.maxDeadGraceMs);
|
|
927
|
+
const patch = { contactFailures: failures, lastContactFailureAt: now, contactRunStartedAt: runStart };
|
|
928
|
+
if (toDead)
|
|
889
929
|
patch.state = 'dead';
|
|
890
930
|
this.store.update(id, patch);
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
|
|
896
|
-
|
|
931
|
+
if (completed && !toDead)
|
|
932
|
+
this.diag.deadGraceHeld++;
|
|
933
|
+
// A change is a strike that progressed the count, or the one that marked the peer dead. A
|
|
934
|
+
// grace-held strike at the clamped threshold moves nothing — it can repeat every pass for
|
|
935
|
+
// minutes, and counting it would pin the idle backoff at its base for the whole grace. So
|
|
936
|
+
// the verdict lands at most one passive interval after the grace expires. A strike on a peer
|
|
937
|
+
// already `dead` (the dead arm re-probing it) is never a change, or every dead-arm probe
|
|
938
|
+
// would reset the delay forever.
|
|
939
|
+
if (e.state !== 'dead' && (failures > e.contactFailures || toDead)) {
|
|
940
|
+
this.noteChange(toDead ? `contact run completed on ${id}` : `contact strike ${failures} on ${id}`);
|
|
941
|
+
}
|
|
897
942
|
}
|
|
898
943
|
/**
|
|
899
944
|
* Record that `id` is demonstrably alive: clear its contact-failure run and, if it was marked
|
|
900
|
-
* `dead`, restore it to a live state.
|
|
945
|
+
* `dead`, restore it to a live state — and advance its session tenure (`store/tenure.ts`).
|
|
901
946
|
*
|
|
902
947
|
* Called from every site that proves liveness: the RPC sites through {@link noteRpcEvidence},
|
|
903
948
|
* and a fresh transport connection (`peer:connect`) directly. The counter reset is the
|
|
@@ -907,28 +952,45 @@ export class FretService {
|
|
|
907
952
|
*
|
|
908
953
|
* Relevance is deliberately *not* reset to a baseline. `scoreSuccess` already up-ranks on
|
|
909
954
|
* success, and wiping the health counters would erase the record of a peer that flaps.
|
|
955
|
+
*
|
|
956
|
+
* This is the **only** place tenure advances, so it counts proof of life and nothing else:
|
|
957
|
+
* hearsay (`noteDiscovered`, address hints, the peerStore listeners) never reaches it. The
|
|
958
|
+
* stamp rides the same write as the liveness patch, and a stamp alone is **not** a change —
|
|
959
|
+
* otherwise every RPC from a settled neighbour would snap the idle backoff back to its base.
|
|
910
960
|
*/
|
|
911
961
|
noteProofOfLife(id) {
|
|
912
962
|
const e = this.store.getById(id);
|
|
913
963
|
if (!e)
|
|
914
964
|
return;
|
|
965
|
+
const patch = this.livenessPatch(e);
|
|
966
|
+
const settled = Object.keys(patch).length === 0;
|
|
967
|
+
// Self never reaches here by RPC; the guard keeps a meaningless tenure off its entry anyway.
|
|
968
|
+
const tenure = id === this.selfIdStr ? undefined : noteProven(e, Date.now());
|
|
969
|
+
if (settled && tenure === undefined)
|
|
970
|
+
return;
|
|
971
|
+
this.store.update(id, { ...patch, ...tenure });
|
|
972
|
+
// Only when the liveness half wrote — a run cleared or a `dead` peer revived. Proof of life
|
|
973
|
+
// from a peer already settled (an idle neighbour's ping) at most stamped tenure, which is
|
|
974
|
+
// not something the next pass could act on.
|
|
975
|
+
if (settled)
|
|
976
|
+
return;
|
|
977
|
+
this.noteChange(patch.state === undefined ? `contact run cleared for ${id}` : `${id} revived`);
|
|
978
|
+
}
|
|
979
|
+
/** What proof of life changes about `e`'s liveness bookkeeping — empty when already settled. */
|
|
980
|
+
livenessPatch(e) {
|
|
915
981
|
const patch = {};
|
|
916
982
|
// The spacing stamp is cleared alongside the count, because it only means anything
|
|
917
983
|
// *within* a run: leaving it behind makes the first failure of the next run land inside
|
|
918
984
|
// the spacing window of a failure from the run just ended, so that strike is discarded
|
|
919
985
|
// and the peer gets a free miss after every recovery.
|
|
920
|
-
if (e.contactFailures !== 0 || e.lastContactFailureAt !== 0) {
|
|
986
|
+
if (e.contactFailures !== 0 || e.lastContactFailureAt !== 0 || e.contactRunStartedAt !== 0) {
|
|
921
987
|
patch.contactFailures = 0;
|
|
922
988
|
patch.lastContactFailureAt = 0;
|
|
989
|
+
patch.contactRunStartedAt = 0;
|
|
923
990
|
}
|
|
924
991
|
if (e.state === 'dead')
|
|
925
|
-
patch.state = this.isConnected(id) ? 'connected' : 'disconnected';
|
|
926
|
-
|
|
927
|
-
return; // already settled
|
|
928
|
-
this.store.update(id, patch);
|
|
929
|
-
// Only when it wrote — a run cleared or a `dead` peer revived. Proof of life from a peer
|
|
930
|
-
// already settled (an idle neighbour's ping) returned above and is not a change.
|
|
931
|
-
this.noteChange(patch.state === undefined ? `contact run cleared for ${id}` : `${id} revived`);
|
|
992
|
+
patch.state = this.isConnected(e.id) ? 'connected' : 'disconnected';
|
|
993
|
+
return patch;
|
|
932
994
|
}
|
|
933
995
|
/**
|
|
934
996
|
* The remote *answered on this network's namespaced protocol* — a framed reply came back,
|
|
@@ -2305,6 +2367,9 @@ export class FretService {
|
|
|
2305
2367
|
// dead self has no path back short of a restart — the same guard `applyContactStrike` keeps.
|
|
2306
2368
|
if (id === this.selfIdStr)
|
|
2307
2369
|
return;
|
|
2370
|
+
// NOTE: immediate whatever the sender's tenure — no dead-verdict grace — because the notice is
|
|
2371
|
+
// the peer's own statement that it is going. A "back in N minutes" leave flavour that earns
|
|
2372
|
+
// grace needs leave authentication first (tickets/backlog/22-debt-leave-authentication.md).
|
|
2308
2373
|
this.store.update(id, { state: 'dead' });
|
|
2309
2374
|
}
|
|
2310
2375
|
/**
|
|
@@ -4101,6 +4166,9 @@ export class FretService {
|
|
|
4101
4166
|
backoffPenalty: (id) => this.backoff.penalty(id),
|
|
4102
4167
|
};
|
|
4103
4168
|
}
|
|
4169
|
+
// NOTE: link quality is the success rate alone; session tenure (`store/tenure.ts`) is available
|
|
4170
|
+
// on the entry but deliberately not folded in. If routes are ever measured failing mid-path on
|
|
4171
|
+
// short-lived hops, weight this by `stabilityFactor(provenTenureMs(entry))`.
|
|
4104
4172
|
linkQuality(id) {
|
|
4105
4173
|
const entry = this.store.getById(id);
|
|
4106
4174
|
if (!entry)
|
|
@@ -4425,6 +4493,10 @@ export class FretService {
|
|
|
4425
4493
|
metadata: entry.metadata
|
|
4426
4494
|
}));
|
|
4427
4495
|
}
|
|
4496
|
+
getPeerTenure(peerId) {
|
|
4497
|
+
const entry = this.store.getById(peerId);
|
|
4498
|
+
return entry ? provenTenureMs(entry) : undefined;
|
|
4499
|
+
}
|
|
4428
4500
|
exportTable() {
|
|
4429
4501
|
const now = Date.now();
|
|
4430
4502
|
return {
|
|
@@ -113,6 +113,7 @@ export declare class Libp2pFretService implements Startable, FretService {
|
|
|
113
113
|
id: string;
|
|
114
114
|
metadata?: Record<string, unknown>;
|
|
115
115
|
}>;
|
|
116
|
+
getPeerTenure(peerId: string): number | undefined;
|
|
116
117
|
exportTable(): SerializedTable;
|
|
117
118
|
importTable(table: SerializedTable): Promise<number>;
|
|
118
119
|
reportNetworkSize(estimate: number, confidence: number, source?: string): void;
|
|
@@ -168,7 +168,7 @@ export class Libp2pFretService {
|
|
|
168
168
|
// dropping the `return` changes nothing a caller can observe. What it does change is
|
|
169
169
|
// `test/libp2p-facade-forwarding.spec.ts`, which injects a mock core returning a distinct
|
|
170
170
|
// sentinel from *every* method and asserts each facade method hands that exact object back —
|
|
171
|
-
// one uniform exact-forwarding rule over all
|
|
171
|
+
// one uniform exact-forwarding rule over all 21 members rather than a per-method judgement
|
|
172
172
|
// about which returns are worth checking. Deleting a `return` here fails that spec with
|
|
173
173
|
// "<method> return identity"; keep them.
|
|
174
174
|
async ready() {
|
|
@@ -190,6 +190,9 @@ export class Libp2pFretService {
|
|
|
190
190
|
listPeers() {
|
|
191
191
|
return this.ensure().listPeers();
|
|
192
192
|
}
|
|
193
|
+
getPeerTenure(peerId) {
|
|
194
|
+
return this.ensure().getPeerTenure(peerId);
|
|
195
|
+
}
|
|
193
196
|
exportTable() {
|
|
194
197
|
return this.ensure().exportTable();
|
|
195
198
|
}
|
|
@@ -59,6 +59,15 @@ export interface PeerEntry {
|
|
|
59
59
|
* serialized — it describes a live attempt and says nothing after a restart. 0 = never counted.
|
|
60
60
|
*/
|
|
61
61
|
lastContactFailureAt: number;
|
|
62
|
+
/**
|
|
63
|
+
* Unix ms of the first strike counted in the current contact-failure run; 0 = no run.
|
|
64
|
+
*
|
|
65
|
+
* The dead-verdict grace (`deadGraceMs` in `store/tenure.ts`) is measured from here: a peer we
|
|
66
|
+
* have watched stay up for a long time is marked `dead` only once its run has both reached the
|
|
67
|
+
* strike threshold *and* lasted that long. Same lifecycle as `lastContactFailureAt` — not
|
|
68
|
+
* serialized, 0 on insert and import, cleared together with the run by proof of life.
|
|
69
|
+
*/
|
|
70
|
+
contactRunStartedAt: number;
|
|
62
71
|
accessCount: number;
|
|
63
72
|
successCount: number;
|
|
64
73
|
failureCount: number;
|
|
@@ -74,6 +83,20 @@ export interface PeerEntry {
|
|
|
74
83
|
* measurements away. Neither mistake is expressible now.
|
|
75
84
|
*/
|
|
76
85
|
avgLatencyMs: number | null;
|
|
86
|
+
/**
|
|
87
|
+
* Local-clock unix ms at which this peer's current *session* began — the first proof of life
|
|
88
|
+
* of a run in which no two consecutive proofs were further apart than the tenure gap tolerance.
|
|
89
|
+
* 0 = never proven. Paired with {@link lastProvenAliveAt}; proven tenure is the span between
|
|
90
|
+
* them (see `store/tenure.ts`). Like the counters, the store stores and exposes it and never
|
|
91
|
+
* branches on it.
|
|
92
|
+
*/
|
|
93
|
+
sessionStartedAt: number;
|
|
94
|
+
/**
|
|
95
|
+
* Local-clock unix ms of the latest proof of life counted toward the current session (stamped
|
|
96
|
+
* at a granularity of about a second, so a burst of RPCs is one write). 0 = never proven.
|
|
97
|
+
* Written only by `FretService.noteProofOfLife`; hearsay never touches it.
|
|
98
|
+
*/
|
|
99
|
+
lastProvenAliveAt: number;
|
|
77
100
|
metadata?: Record<string, unknown>;
|
|
78
101
|
/**
|
|
79
102
|
* This peer's most recent **verified** signed address record — the marshaled libp2p
|
|
@@ -137,6 +160,8 @@ export interface SerializedPeerEntry {
|
|
|
137
160
|
successCount: number;
|
|
138
161
|
failureCount: number;
|
|
139
162
|
avgLatencyMs: number | null;
|
|
163
|
+
sessionStartedAt?: number;
|
|
164
|
+
lastProvenAliveAt?: number;
|
|
140
165
|
metadata?: Record<string, unknown>;
|
|
141
166
|
addressRecord?: SerializedAddressRecord;
|
|
142
167
|
}
|
|
@@ -209,10 +209,13 @@ export class DigitreeStore {
|
|
|
209
209
|
lastNegotiateFailureAt: 0,
|
|
210
210
|
contactFailures: 0,
|
|
211
211
|
lastContactFailureAt: 0,
|
|
212
|
+
contactRunStartedAt: 0,
|
|
212
213
|
accessCount: 0,
|
|
213
214
|
successCount: 0,
|
|
214
215
|
failureCount: 0,
|
|
215
|
-
avgLatencyMs: null
|
|
216
|
+
avgLatencyMs: null,
|
|
217
|
+
sessionStartedAt: 0,
|
|
218
|
+
lastProvenAliveAt: 0
|
|
216
219
|
});
|
|
217
220
|
}
|
|
218
221
|
update(id, patch) {
|
|
@@ -442,6 +445,8 @@ export class DigitreeStore {
|
|
|
442
445
|
successCount: e.successCount,
|
|
443
446
|
failureCount: e.failureCount,
|
|
444
447
|
avgLatencyMs: e.avgLatencyMs,
|
|
448
|
+
sessionStartedAt: e.sessionStartedAt,
|
|
449
|
+
lastProvenAliveAt: e.lastProvenAliveAt,
|
|
445
450
|
...(e.metadata ? { metadata: e.metadata } : {}),
|
|
446
451
|
...(e.addressRecord ? { addressRecord: serializeAddressRecord(e.addressRecord) } : {}),
|
|
447
452
|
}));
|
|
@@ -486,6 +491,7 @@ export class DigitreeStore {
|
|
|
486
491
|
// on its very next failure.
|
|
487
492
|
contactFailures: 0,
|
|
488
493
|
lastContactFailureAt: 0,
|
|
494
|
+
contactRunStartedAt: 0,
|
|
489
495
|
// NOTE: a snapshot taken before frequency credit was narrowed to proven contact
|
|
490
496
|
// carries an accessCount that also counted mentions, so a restored entry can be
|
|
491
497
|
// over-credited. Self-correcting — the next scoring call rewrites relevance from
|
|
@@ -499,6 +505,12 @@ export class DigitreeStore {
|
|
|
499
505
|
// reads back as a genuine 0 ms — accepted, since the next ping overwrites it and
|
|
500
506
|
// a coercion would instead discard real 0 ms measurements.
|
|
501
507
|
avgLatencyMs: s.avgLatencyMs ?? null,
|
|
508
|
+
// Restored rather than reset: tenure is history, and a proof after a restart longer
|
|
509
|
+
// than the gap tolerance starts a fresh session anyway. NOTE: a tampered file can
|
|
510
|
+
// inflate it, exactly as it can inflate the imported relevance and counters — table
|
|
511
|
+
// signing (tickets/backlog/23-debt-routing-table-export-signing.md) closes that class.
|
|
512
|
+
sessionStartedAt: s.sessionStartedAt ?? 0,
|
|
513
|
+
lastProvenAliveAt: s.lastProvenAliveAt ?? 0,
|
|
502
514
|
...(s.metadata ? { metadata: s.metadata } : {}),
|
|
503
515
|
...(addressRecord ? { addressRecord } : {}),
|
|
504
516
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { minDistance, normalizedLogMagnitude } from '../ring/distance.js';
|
|
2
|
+
import { provenTenureMs, stabilityFactor } from './tenure.js';
|
|
2
3
|
export function createSparsityModel(m = 12, sigma = 0.08, alpha = 0.03, beta = 0.6, sMin = 0.7, sMax = 1.8, eps = 1e-6) {
|
|
3
4
|
const centers = [];
|
|
4
5
|
for (let i = 0; i < m; i++)
|
|
@@ -86,11 +87,20 @@ function baseRelevance(entry, now) {
|
|
|
86
87
|
wFreq * frequencyScore(entry) +
|
|
87
88
|
wHealth * healthScore(entry));
|
|
88
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* `base · S(x) · stability`: every scoring function's final step. Stability comes from the
|
|
92
|
+
* entry's *proven* tenure (`store/tenure.ts`), which is 0 — factor 1 — for a brand-new or hearsay
|
|
93
|
+
* entry, so only a peer this node has itself watched stay up earns the multiplier. A failing peer
|
|
94
|
+
* is scored with its tenure frozen at the last proof, which is what lets a long-lived server under
|
|
95
|
+
* maintenance keep its edge at eviction over a fresh peer with similar counters.
|
|
96
|
+
*/
|
|
97
|
+
function weighted(base, entry, x, model) {
|
|
98
|
+
return base * sparsityBonus(model, x) * stabilityFactor(provenTenureMs(entry));
|
|
99
|
+
}
|
|
89
100
|
export function touch(entry, x, model, now = Date.now()) {
|
|
90
101
|
observeDistance(model, x);
|
|
91
102
|
const base = baseRelevance({ ...entry, accessCount: entry.accessCount + 1 }, now);
|
|
92
|
-
const
|
|
93
|
-
const relevance = base * bonus;
|
|
103
|
+
const relevance = weighted(base, entry, x, model);
|
|
94
104
|
return {
|
|
95
105
|
...entry,
|
|
96
106
|
lastAccess: now,
|
|
@@ -104,9 +114,7 @@ export function touch(entry, x, model, now = Date.now()) {
|
|
|
104
114
|
* a distance we accessed.
|
|
105
115
|
*/
|
|
106
116
|
export function initialRelevance(entry, x, model, now = Date.now()) {
|
|
107
|
-
|
|
108
|
-
const bonus = sparsityBonus(model, x);
|
|
109
|
-
return base * bonus;
|
|
117
|
+
return weighted(baseRelevance(entry, now), entry, x, model);
|
|
110
118
|
}
|
|
111
119
|
/**
|
|
112
120
|
* Blend a new latency sample into a peer's running average.
|
|
@@ -142,8 +150,7 @@ export function recordSuccess(entry, latencyMs, x, model, now = Date.now()) {
|
|
|
142
150
|
observeDistance(model, x);
|
|
143
151
|
const avgLatencyMs = blendLatency(entry.avgLatencyMs, latencyMs);
|
|
144
152
|
const base = baseRelevance({ ...entry, avgLatencyMs, successCount: entry.successCount + 1, accessCount: entry.accessCount + 1 }, now);
|
|
145
|
-
const
|
|
146
|
-
const relevance = base * bonus;
|
|
153
|
+
const relevance = weighted(base, entry, x, model);
|
|
147
154
|
return {
|
|
148
155
|
...entry,
|
|
149
156
|
lastAccess: now,
|
|
@@ -157,8 +164,7 @@ export function recordFailure(entry, x, model, now = Date.now()) {
|
|
|
157
164
|
observeDistance(model, x);
|
|
158
165
|
// degrade relevance softly
|
|
159
166
|
const base = baseRelevance({ ...entry, failureCount: entry.failureCount + 1 }, now) * 0.7;
|
|
160
|
-
const
|
|
161
|
-
const relevance = base * bonus;
|
|
167
|
+
const relevance = weighted(base, entry, x, model);
|
|
162
168
|
return {
|
|
163
169
|
...entry,
|
|
164
170
|
lastAccess: now,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session tenure: how long this node has *itself* watched a peer stay up, measured from proof of
|
|
3
|
+
* life only — a pure module with no clock of its own (callers pass `now`) and no service
|
|
4
|
+
* dependencies, so the rules are unit-testable without sleeps.
|
|
5
|
+
*
|
|
6
|
+
* A **session** is a run of proofs of life in which no two consecutive proofs are more than
|
|
7
|
+
* {@link TENURE_GAP_TOLERANCE_MS} apart. **Proven tenure** is `lastProvenAliveAt −
|
|
8
|
+
* sessionStartedAt`: the span actually observed, frozen while the peer is silent — it never grows
|
|
9
|
+
* on the clock alone, so a peer that went quiet gets no credit for the time it was away. The gap
|
|
10
|
+
* is measured on our clock, so our own downtime past the tolerance restarts every session too: we
|
|
11
|
+
* cannot vouch for time we did not observe.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here is gossiped. Tenure is what *this* node saw, so no other peer can inflate it — the
|
|
14
|
+
* same rule as frequency credit (`FretService.noteDiscovered`).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The longest silence a session survives. Covers "a few minutes of maintenance" with room to
|
|
18
|
+
* spare, and must stay comfortably above the longest regular contact interval for a peer we
|
|
19
|
+
* actively watch — the near pass at the idle-backoff cap (60 s) and the dead re-probe window
|
|
20
|
+
* (at most 32 s) — or a healthy neighbour's session would break on its own. Not longer: a longer
|
|
21
|
+
* tolerance lets an intermittent peer accumulate tenure across its absences.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TENURE_GAP_TOLERANCE_MS: number;
|
|
24
|
+
/** Below this many ms between stamps, a proof refreshes nothing (write coalescing). */
|
|
25
|
+
export declare const TENURE_STAMP_GRANULARITY_MS = 1000;
|
|
26
|
+
/** Tenure at which the stability factor reaches half its maximum bonus. */
|
|
27
|
+
export declare const STABILITY_HALF_TENURE_MS: number;
|
|
28
|
+
/** Maximum relevance multiplier bonus: factor ∈ [1, 1 + STABILITY_MAX_BONUS). */
|
|
29
|
+
export declare const STABILITY_MAX_BONUS = 0.5;
|
|
30
|
+
/** The two tenure stamps a routing-table entry carries — local-clock unix ms, 0 = never proven. */
|
|
31
|
+
export interface TenureFields {
|
|
32
|
+
sessionStartedAt: number;
|
|
33
|
+
lastProvenAliveAt: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The patch a proof of life at `now` implies, or `undefined` when nothing needs writing.
|
|
37
|
+
*
|
|
38
|
+
* Continues the current session when the gap since the last proof is within the tolerance
|
|
39
|
+
* (inclusive); otherwise — never proven, too long a silence, or `now` before the last stamp (the
|
|
40
|
+
* clock went backwards, or the table came from a skewed clock) — starts a new one at `now`, so
|
|
41
|
+
* tenure is never negative. A continuing proof less than {@link TENURE_STAMP_GRANULARITY_MS}
|
|
42
|
+
* after the last stamp writes nothing, so a burst of RPCs costs one store write, not one per
|
|
43
|
+
* message.
|
|
44
|
+
*/
|
|
45
|
+
export declare function noteProven(cur: TenureFields, now: number): TenureFields | undefined;
|
|
46
|
+
/** Proven tenure in ms: 0 when never proven, and never negative or non-finite. */
|
|
47
|
+
export declare function provenTenureMs(cur: TenureFields): number;
|
|
48
|
+
/**
|
|
49
|
+
* The relevance multiplier tenure earns: `1 + MAX · t / (t + HALF)` — 1 at zero tenure, about
|
|
50
|
+
* 1.038 at 5 minutes, 1.25 at an hour, 1.48 at a day. Monotone and bounded below
|
|
51
|
+
* `1 + STABILITY_MAX_BONUS`, so tenure can tip a close eviction but never dominate the other
|
|
52
|
+
* relevance terms.
|
|
53
|
+
*/
|
|
54
|
+
export declare function stabilityFactor(tenureMs: number): number;
|
|
55
|
+
/** Below this proven tenure a peer gets no dead-verdict grace: newcomers keep the fast verdict. */
|
|
56
|
+
export declare const DEAD_GRACE_MIN_TENURE_MS: number;
|
|
57
|
+
/**
|
|
58
|
+
* Grace earned per unit of proven tenure: a peer we have watched stay up for T is given T/12 to
|
|
59
|
+
* come back before it is declared dead. A judgement call, not a measurement — Lindy-shaped: the
|
|
60
|
+
* longer a peer has been up, the longer an absence is still plausibly "brief".
|
|
61
|
+
*/
|
|
62
|
+
export declare const DEAD_GRACE_TENURE_RATIO: number;
|
|
63
|
+
/**
|
|
64
|
+
* How long a contact-failure run against a peer with `tenureMs` of proven tenure must last before
|
|
65
|
+
* the peer may be marked `dead`, capped at `capMs` (`FretConfig.maxDeadGraceMs`; 0 disables grace).
|
|
66
|
+
*
|
|
67
|
+
* 0 below {@link DEAD_GRACE_MIN_TENURE_MS}, so a phone that appeared five minutes ago is judged
|
|
68
|
+
* exactly as before; at the default 5-minute cap, 50 s at 10 minutes of tenure, 2.5 minutes at
|
|
69
|
+
* 30, and the full cap from an hour on. Strike count alone cannot do this — strikes arrive at the
|
|
70
|
+
* stabilization pass rate, so a larger count stretches seconds, not minutes.
|
|
71
|
+
*/
|
|
72
|
+
export declare function deadGraceMs(tenureMs: number, capMs: number): number;
|