first commit
This commit is contained in:
137
docs/networking/network/network-discovery-hybrid/README.md
Normal file
137
docs/networking/network/network-discovery-hybrid/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,108 @@
|
||||
# HybridDiscoveryManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Top-level orchestrator composing any subset of the five Wave-2 sibling discovery strategies (peerjs / crdt / trystero / webtorrent / gundb) into a single composite that itself implements the `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. The manager fans `discover()` out across every healthy strategy, merges the results through the `PeerTracker` for de-duplication with `PeerSource` provenance, and surfaces a unified `HybridManagerStatus` snapshot.
|
||||
|
||||
Because the manager implements `DiscoveryStrategy` itself, it can be chained into a higher-level `DiscoveryChain` from `@suaveplan/network-bootstrap` — the hybrid is effectively a recursive composer.
|
||||
|
||||
## Features
|
||||
|
||||
- Composes any subset of the five Wave-2 strategies plus arbitrary `"custom"` strategies registered via `StrategyConfig.strategy`.
|
||||
- Priority-based peer selection (`priority` mode), weighted concurrent fan-out (`weighted`), or fastest-non-empty-wins race (`fastest`).
|
||||
- Per-strategy `StrategyHealth` tracking via the embedded `StrategyManager` (rolling success-rate EMA, consecutive failures, last error, peer count).
|
||||
- Automatic fallback on degradation — an unavailable primary triggers a dormant fallback/backup to initialise.
|
||||
- De-duplicated peer set with `PeerSource` provenance (`discoveredBy` is an ordered list of every strategy that has ever observed the peer).
|
||||
- Unified lifecycle (`start` / `stop` / `dispose`) — idempotent; `stop()` cancels every periodic loop atomically and clears the peer set.
|
||||
- Periodic health-check + stale-peer sweep driven by `@suaveplan/timing` `Scheduler` with an injectable clock for deterministic tests.
|
||||
- AbortSignal threaded through every async path; `discover(timeoutMs, signal)` honours both the caller's signal and the internal `discoveryTimeoutMs` cap.
|
||||
- Optional `@suaveplan/events` `EventBusLike<HybridDiscoveryEventMap>` integration for typed observability.
|
||||
- OTel surface: `hybrid.peer_sources_total`, `hybrid.strategy_health_score`, `hybrid.fallback_total`, `hybrid.active_strategies`, `hybrid.discover_duration_ms`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { HybridDiscoveryManager } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
const manager = new HybridDiscoveryManager({
|
||||
strategy: "hybrid",
|
||||
strategies: [
|
||||
{ id: "peerjs", priority: "primary", strategy: peerJsStrategy },
|
||||
{ id: "trystero", priority: "fallback", strategy: trysteroStrategy },
|
||||
],
|
||||
});
|
||||
|
||||
await manager.start();
|
||||
const peers = await manager.discover(5_000);
|
||||
await manager.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createEventBus } from "@suaveplan/events";
|
||||
import { HybridDiscoveryManager } from "@suaveplan/network-discovery-hybrid";
|
||||
import type { HybridDiscoveryEventMap } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
const bus = createEventBus<HybridDiscoveryEventMap>();
|
||||
bus.on("hybrid:fallback", ({ from, to }) =>
|
||||
console.log(`Fallback ${from} → ${to}`)
|
||||
);
|
||||
|
||||
const manager = new HybridDiscoveryManager(
|
||||
{
|
||||
strategy: "hybrid",
|
||||
loadBalancing: "fastest",
|
||||
autoFallback: true,
|
||||
deduplication: true,
|
||||
discoveryTimeoutMs: 8_000,
|
||||
peerStaleMs: 30_000,
|
||||
maxPeers: 500,
|
||||
strategies: [
|
||||
{ id: "peerjs", priority: "primary", weight: 70 },
|
||||
{
|
||||
id: "trystero",
|
||||
priority: "fallback",
|
||||
weight: 30,
|
||||
factory: async () => {
|
||||
const mod = await import("@suaveplan/network-discovery-trystero");
|
||||
return new mod.TrysteroDiscovery({ /* ... */ });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ bus }
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
await manager.start(controller.signal);
|
||||
try {
|
||||
const peers = await manager.discover(5_000, controller.signal);
|
||||
console.log(`Discovered ${peers.length} peers`);
|
||||
console.log(manager.getStatus());
|
||||
} finally {
|
||||
await manager.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new HybridDiscoveryManager(config, options?)` — config validated via Zod; throws `INVALID_OPTIONS` on shape failure or `NO_STRATEGIES` on empty `strategies`. `options.bus` accepts an `EventBusLike<HybridDiscoveryEventMap>`.
|
||||
- `start(signal?)` — initialise every strategy in priority order. Throws `ALL_STRATEGIES_DEGRADED` when no strategy initialises successfully.
|
||||
- `stop(signal?)` — stop every active strategy + cancel periodic loops. Idempotent.
|
||||
- `dispose(signal?)` — drive a hard shutdown; subsequent methods throw `DISPOSED`.
|
||||
- `discover(timeoutMs, signal?)` — fan out discovery, merge results into the deduped set, return the current snapshot. Honours both the supplied `signal` and the config-level `discoveryTimeoutMs` cap (whichever is shorter).
|
||||
- `isAvailable(signal?)` — `true` when running and at least one strategy is healthy.
|
||||
- `getStatus()` — snapshot of `running`, `activeStrategies`, `totalPeers`, `peersByStrategy`, `strategies`, `loadBalancing`.
|
||||
- `getPeerCount()` — total deduped peer count.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`discover()` is the hot path. It composes the caller's `signal` with an internal `setTimeout` that fires after `min(timeoutMs, config.discoveryTimeoutMs)` ms, propagates abort to every strategy via the composite signal, and always cleans up its `setTimeout` in the `finally` block so a fast resolve does not leak a timer. The composite signal's outcome (`ok` / `empty` / `error` / `timeout`) is folded into the `hybrid.discover_duration_ms` histogram.
|
||||
|
||||
`PeerTracker` is owned exclusively by the manager. The two callbacks wired into the tracker forward dedup events directly to the optional bus. The `PeerSource.discoveredBy` array is intentionally append-only — once a strategy has seen a peer, it remains in the provenance chain until either the peer loses every strategy or the peer is swept for being stale. This guarantees provenance is a monotonic record per peer.
|
||||
|
||||
`Lifecycle` is started inside `start()` only after at least one strategy is active. This avoids a brief window where the health-check loop could fire before any strategy is registered and produce a no-op tick.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Lifecycle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Self-rearming periodic loops orchestrating the hybrid manager's background work — running health checks against every registered strategy and sweeping stale peers from the `PeerTracker`. Replaces the upstream `setInterval` triple with a single injectable `Scheduler` from `@suaveplan/timing` so disposal is atomic and tests can drive the timeline deterministically.
|
||||
|
||||
The `Lifecycle` is a thin loop runner — it does not own any peer state, does not know about strategy health, and never communicates with the bus. The orchestrator (`HybridDiscoveryManager`) wires the loop callbacks to the `StrategyManager` and `PeerTracker` instances it owns.
|
||||
|
||||
## Features
|
||||
|
||||
- Two self-rearming loops: health check + stale-peer sweep.
|
||||
- `Scheduler`-driven with injectable `clock` for deterministic tests.
|
||||
- Idempotent `start()` and `stop()`.
|
||||
- `stop()` disposes the scheduler atomically — pending timers are cancelled in one operation.
|
||||
- Health-check failures are absorbed (the underlying `StrategyManager` records them into per-strategy health).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { Lifecycle } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
const lifecycle = new Lifecycle({
|
||||
clock: () => Date.now(),
|
||||
healthCheckIntervalMs: 30_000,
|
||||
cleanupIntervalMs: 30_000,
|
||||
runHealthCheck: () => strategyManager.runHealthCheck(),
|
||||
sweepStalePeers: () => peerTracker.sweepStale(60_000),
|
||||
});
|
||||
|
||||
lifecycle.start();
|
||||
// ... later
|
||||
lifecycle.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { Lifecycle } from "@suaveplan/network-discovery-hybrid";
|
||||
import { createScheduler } from "@suaveplan/timing";
|
||||
|
||||
// Inject a custom scheduler factory — useful for tests that want to
|
||||
// install a manual clock and drain the queue on demand.
|
||||
const lifecycle = new Lifecycle(
|
||||
{
|
||||
clock,
|
||||
healthCheckIntervalMs: 1_000,
|
||||
cleanupIntervalMs: 5_000,
|
||||
runHealthCheck: async () => mgr.runHealthCheck(),
|
||||
sweepStalePeers: () => tracker.sweepStale(60_000),
|
||||
},
|
||||
(c) => createScheduler({ clock: c })
|
||||
);
|
||||
|
||||
lifecycle.start();
|
||||
lifecycle.stop(); // safe to call multiple times
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Lifecycle(deps, schedulerFactory?)` — `deps` carries the clock, the two interval cadences, and the two callbacks. `schedulerFactory` defaults to `createScheduler` from `@suaveplan/timing`.
|
||||
- `start()` — kick off both loops. Idempotent; no-op after `stop()`.
|
||||
- `stop()` — dispose the scheduler. Idempotent.
|
||||
- `isStarted` — `true` after `start()` has been called and before `stop()`.
|
||||
- `isStopped` — `true` after `stop()` has been called.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The two loops use `Scheduler.schedule` (one-shot) and re-arm themselves after every fire. This is preferable to `setInterval` because it avoids the "tick storm" where a slow health-check holds the runtime past its next-tick boundary and the scheduler enqueues multiple back-to-back fires. A re-arming one-shot only schedules the next tick after the current one has settled.
|
||||
|
||||
`scheduleSafe` guards against `Scheduler.disposed` so calls that race a concurrent `stop()` silently no-op instead of throwing `TIMING.SCHEDULER.DISPOSED`. This is the safe choice — by the time the scheduler is disposed, the orchestrator has already torn down every other piece of state and there is nothing meaningful for a stray callback to do.
|
||||
@@ -0,0 +1,84 @@
|
||||
# PeerTracker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
In-memory, dedup-aware peer registry that records every peer observation from every registered discovery strategy alongside its `PeerSource` provenance. The tracker is a pure data structure with synchronous methods only — it does no I/O, owns no timers, and is safe to drop into any orchestrator that needs to merge concurrent peer streams from heterogeneous strategies.
|
||||
|
||||
## Features
|
||||
|
||||
- Deduplication by `PeerInfo.peerId` with append-only `discoveredBy` provenance.
|
||||
- Stable `primaryStrategy` field — the first strategy to ever observe the peer.
|
||||
- Graceful primary-strategy demotion on peer-loss when multiple strategies still see the peer.
|
||||
- Configurable `maxPeers` with LRU-by-lastSeen eviction.
|
||||
- `sweepStale(staleMs)` for ageing out peers whose `lastSeen` is older than the threshold.
|
||||
- Per-strategy peer counts via `countFor(strategy)`.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- Optional `onPeerDiscovered` / `onPeerLost` callbacks for forwarding to a bus.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { PeerTracker } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
const tracker = new PeerTracker();
|
||||
tracker.observe(
|
||||
{
|
||||
peerId: "alice",
|
||||
addresses: [],
|
||||
lastSeen: Date.now(),
|
||||
source: "peerjs",
|
||||
},
|
||||
"peerjs"
|
||||
);
|
||||
console.log(tracker.size()); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { PeerTracker } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
let clockNow = 1_000;
|
||||
const tracker = new PeerTracker(
|
||||
{
|
||||
onPeerDiscovered: (peer, strategy) =>
|
||||
console.log(`+${peer.peerId} via ${strategy}`),
|
||||
onPeerLost: (peerId) => console.log(`-${peerId}`),
|
||||
},
|
||||
{ clock: () => clockNow, maxPeers: 5, dedup: true }
|
||||
);
|
||||
|
||||
const peer = {
|
||||
peerId: "alice",
|
||||
addresses: [],
|
||||
lastSeen: clockNow,
|
||||
source: "peerjs",
|
||||
};
|
||||
|
||||
tracker.observe(peer, "peerjs");
|
||||
tracker.observe(peer, "trystero"); // dedupes; discoveredBy = ["peerjs", "trystero"]
|
||||
|
||||
clockNow = 100_000;
|
||||
tracker.sweepStale(10_000); // ages out "alice"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new PeerTracker(callbacks?, options?)` — `callbacks` may include `onPeerDiscovered` and `onPeerLost`; `options` accepts `clock`, `maxPeers` (default 1000), `dedup` (default true).
|
||||
- `observe(peer, strategy)` — record a peer observation. New peer → emits `onPeerDiscovered`; existing peer → appends `strategy` to `discoveredBy` and refreshes `lastSeen`.
|
||||
- `lose(peerId, strategy)` — record a peer-loss from a strategy. Demotes primary when needed; removes the peer when no strategies remain (or always when `dedup: false`).
|
||||
- `sweepStale(staleMs)` — return the number of peers removed for being older than `now - staleMs`.
|
||||
- `snapshot()` — readonly snapshot of every `PeerSource`.
|
||||
- `peers()` — readonly snapshot of every `PeerInfo`.
|
||||
- `size()` — current peer count.
|
||||
- `countFor(strategy)` — peer count attributable to `strategy`.
|
||||
- `clear()` — drop every tracked peer (silently — does not fire callbacks).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `discoveredBy` array is intentionally an array, not a `Set`, so insertion order is observable — the primary strategy is always the first entry, and the second entry is the natural successor when the primary loses the peer. This makes the demotion logic deterministic without needing a separate priority register.
|
||||
|
||||
When `maxPeers` is hit, the tracker evicts the peer with the smallest `lastSeen` value (least-recently-seen). This is preferable to FIFO because a long-lived peer that has been re-observed many times should outlive a fresh peer that was seen once and never again. The eviction emits `onPeerLost` so downstream listeners can react.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Strategy Loader
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Resolves a registered `StrategyConfig` to a concrete `DiscoveryStrategy` instance from `@suaveplan/network-bootstrap`. The loader bridges the two registration paths supported by the hybrid orchestrator: pre-built strategies (handed in directly for tests and statically-imported production code) and lazy factories (for optional peer-deps resolved via dynamic `import()`).
|
||||
|
||||
The loader is the single component that knows how to convert configuration into a live strategy, and it is responsible for translating every failure mode into the typed `STRATEGY_LOAD_FAILED` code so the orchestrator can branch on a stable contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Two registration paths: pre-built `strategy` or lazy `factory`.
|
||||
- Wraps every thrown error (sync or async) in `NetDiscHybridError(STRATEGY_LOAD_FAILED)` with `cause` preserved and `context.id` set to the strategy id.
|
||||
- No static import of sibling discovery packages — optional peer-deps stay optional.
|
||||
- Returns the resolved strategy synchronously when `config.strategy` is already provided (no Promise round-trip).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { loadStrategy } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
const strategy = await loadStrategy({
|
||||
id: "peerjs",
|
||||
priority: "primary",
|
||||
strategy: prebuiltPeerJsStrategy,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { loadStrategy } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
// Factory pattern — peer-dep is resolved lazily via dynamic import()
|
||||
const strategy = await loadStrategy({
|
||||
id: "trystero",
|
||||
priority: "fallback",
|
||||
factory: async () => {
|
||||
const mod = await import("@suaveplan/network-discovery-trystero");
|
||||
return new mod.TrysteroDiscovery({ /* ... */ });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `loadStrategy(config)` — async. Returns the resolved `DiscoveryStrategy`. Throws `NetDiscHybridError(STRATEGY_LOAD_FAILED)` when both `strategy` and `factory` are absent, when the factory throws, or when the factory rejects.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The loader does not memoise — every call to `loadStrategy` invokes `factory()` fresh. Memoisation is the orchestrator's responsibility (it caches the resolved strategy in the `StrategyManager` so subsequent `start()` / `stop()` operate against the same instance). This separation keeps the loader simple and testable.
|
||||
|
||||
Optional peer-deps are intentionally not imported statically by this module — the caller's factory is responsible for the dynamic `import()` and is the natural place to catch `ERR_MODULE_NOT_FOUND` and translate it into a more specific user-facing message. The loader's job is just to lift any thrown failure into the structured-error wire format.
|
||||
@@ -0,0 +1,81 @@
|
||||
# StrategyManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Per-strategy lifecycle, health bookkeeping, and priority-based fallback orchestration. The `StrategyManager` owns the `StrategyHealth` record for every registered strategy, drives `initializeAll()` / `stopAll()`, fans `discover()` calls out concurrently or under a fastest-wins race, and runs the periodic `runHealthCheck()` step that triggers fallback when a primary degrades.
|
||||
|
||||
It is a pure orchestration component — it never instantiates timers itself (the `Lifecycle` schedules health checks against it) and never speaks directly to a bus (it surfaces transitions through opt-in callbacks the `HybridDiscoveryManager` wires to an `EventBusLike`).
|
||||
|
||||
## Features
|
||||
|
||||
- Three-bucket priority ordering: `primary` → `fallback` → `backup`.
|
||||
- Lazy strategy load via `loadStrategy`; load failures recorded into `StrategyHealth` without aborting the bootstrap.
|
||||
- Rolling success-rate per strategy (0.7-weighted EMA) folded into OTel `hybrid.strategy_health_score`.
|
||||
- `discoverAll()` supports `priority` / `weighted` fan-out and `fastest` race-to-first.
|
||||
- `runHealthCheck()` calls `isAvailable()` on every live strategy and triggers `tryFallback()` when `autoFallback` is enabled.
|
||||
- Active-strategy gauge incremented / decremented exactly once per lifecycle transition (OTel `hybrid.active_strategies`).
|
||||
- `DUPLICATE_STRATEGY` thrown at construction when two configs share the same `id`.
|
||||
- AbortSignal threaded through `initializeAll`, `stopAll`, `discoverAll`, and `runHealthCheck`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { StrategyManager } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
const mgr = new StrategyManager({
|
||||
strategy: "hybrid",
|
||||
strategies: [{ id: "peerjs", priority: "primary", strategy: prebuilt }],
|
||||
loadBalancing: "priority",
|
||||
autoFallback: true,
|
||||
deduplication: true,
|
||||
discoveryTimeoutMs: 10_000,
|
||||
peerStaleMs: 60_000,
|
||||
maxPeers: 1000,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
await mgr.initializeAll();
|
||||
const byStrategy = await mgr.discoverAll();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { StrategyManager } from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
const mgr = new StrategyManager(resolvedConfig, {
|
||||
onStrategyActive: (id) => bus.emit("hybrid:strategy-active", { id }),
|
||||
onStrategyInactive: (id) => bus.emit("hybrid:strategy-inactive", { id }),
|
||||
onStrategyFailed: (id, err) =>
|
||||
bus.emit("hybrid:strategy-failed", { id, err }),
|
||||
onFallback: (from, to) => bus.emit("hybrid:fallback", { from, to }),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
await mgr.initializeAll(controller.signal);
|
||||
|
||||
setInterval(() => mgr.runHealthCheck(controller.signal), 30_000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new StrategyManager(config, callbacks?)` — `config` is `ResolvedHybridDiscoveryConfig`. Throws `DUPLICATE_STRATEGY` on dup ids.
|
||||
- `initializeAll(signal?)` — lazy-load + start every strategy in priority order. Individual failures recorded, never thrown.
|
||||
- `stopAll(signal?)` — stop every active strategy. Errors recorded, never thrown.
|
||||
- `discoverAll(signal?)` — fan out discovery; returns `ReadonlyMap<StrategyId, readonly PeerInfo[]>`.
|
||||
- `runHealthCheck(signal?)` — probe `isAvailable()` on every live strategy; trigger `tryFallback` when needed.
|
||||
- `snapshotHealth()` — readonly array of `StrategyHealth`.
|
||||
- `activeIds()` — list of active+healthy strategy ids.
|
||||
- `updatePeerCount(id, count)` — update the per-strategy peer counter (called by the orchestrator after a `PeerTracker` sweep).
|
||||
- `hasActiveStrategy()` — `true` when at least one strategy is active + healthy.
|
||||
- `getEntry(id)` — read-only `{ config, health }` snapshot.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rolling success-rate uses an EMA with `α = 0.3` so a single failure drops the score noticeably without forgetting prior history. This score is the value recorded into `hybrid.strategy_health_score` and what dashboards should pivot on to spot a strategy degrading before it fully fails.
|
||||
|
||||
`raceFastest` is a hand-rolled `Promise` builder rather than `Promise.race` because the race must wait for a non-empty result, not just the first settlement — a strategy that resolves with `[]` should not win the race against a slower strategy that returns real peers. The builder tracks `outstanding` to detect "all strategies returned empty" and resolve with `null` so the orchestrator knows nothing was found.
|
||||
|
||||
Fallback walks the priority order (`fallback` before `backup`) and picks the first dormant entry. The `recordFallback` OTel counter is incremented exactly once per successful promotion so the `from→to` cardinality stays bounded to known strategy pairs.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Hybrid Discovery Errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Frozen error registry for `@suaveplan/network-discovery-hybrid`. Every public boundary throws `NetDiscHybridError` and consumers branch on `error.code`. The registry is the single contract the orchestrator exposes for failure modes — it is intentionally compact so consumers can exhaustively handle each case.
|
||||
|
||||
The registry is registered via `defineCodes` from `@suaveplan/error`, so the codes participate in the global error-code uniqueness gate enforced at build time. All codes share the `NET_DISC_HYBRID.` prefix.
|
||||
|
||||
## Features
|
||||
|
||||
- Six stable codes covering construction, lifecycle, and degradation failure paths.
|
||||
- `NetDiscHybridError` subclasses `SuaveplanError<NetDiscHybridErrorCode>` so it integrates with the structured-error wire format, OTel span events, and i18n message resolution.
|
||||
- Each thrown error carries an i18n message key (e.g. `errors.hybrid.invalid_options`) — the actual translation is resolved by the consumer's i18n stack.
|
||||
- Includes the chained-cause when a Zod parse failure or a dynamic-import failure is the root cause.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NET_DISC_HYBRID_CODES,
|
||||
NetDiscHybridError,
|
||||
} from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
try {
|
||||
// ... call hybrid manager
|
||||
} catch (error) {
|
||||
if (error instanceof NetDiscHybridError) {
|
||||
if (error.code === NET_DISC_HYBRID_CODES.NO_STRATEGIES) {
|
||||
console.error("No strategies configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NET_DISC_HYBRID_CODES,
|
||||
NetDiscHybridError,
|
||||
} from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
function describe(err: NetDiscHybridError): string {
|
||||
switch (err.code) {
|
||||
case NET_DISC_HYBRID_CODES.NO_STRATEGIES:
|
||||
return "no strategies were registered";
|
||||
case NET_DISC_HYBRID_CODES.ALL_STRATEGIES_DEGRADED:
|
||||
return "every strategy is unavailable";
|
||||
case NET_DISC_HYBRID_CODES.STRATEGY_LOAD_FAILED:
|
||||
return "an optional discovery peer-dep failed to load";
|
||||
case NET_DISC_HYBRID_CODES.DUPLICATE_STRATEGY:
|
||||
return "duplicate StrategyConfig.id detected";
|
||||
case NET_DISC_HYBRID_CODES.INVALID_OPTIONS:
|
||||
return "configuration failed Zod validation";
|
||||
case NET_DISC_HYBRID_CODES.DISPOSED:
|
||||
return "operation attempted after stop()";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NET_DISC_HYBRID_CODES` — frozen object with six keys (`NO_STRATEGIES`, `ALL_STRATEGIES_DEGRADED`, `STRATEGY_LOAD_FAILED`, `DUPLICATE_STRATEGY`, `INVALID_OPTIONS`, `DISPOSED`), each mapping to the namespaced string code.
|
||||
- `NetDiscHybridErrorCode` — type union over the registry values.
|
||||
- `NetDiscHybridError extends SuaveplanError<NetDiscHybridErrorCode>` — constructor `(code, messageKey, options?)`. Sets `name = "NetDiscHybridError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`STRATEGY_LOAD_FAILED` is intentionally non-terminal — the hybrid manager catches it internally, records the failure into the strategy's `StrategyHealth`, and continues with the remaining strategies. Only when every strategy has failed does the manager surface `ALL_STRATEGIES_DEGRADED` to the caller. This matches the upstream graceful-degradation behaviour and is what makes the orchestrator robust when an optional peer-dep is absent in the consumer's `node_modules`.
|
||||
|
||||
The six-code registry is the complete public surface. Any new failure path discovered during implementation should be classified into one of the existing codes rather than expanding the registry — the smaller the surface, the easier consumers can write exhaustive handlers.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Hybrid Discovery — OpenTelemetry Surface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Exposes the OpenTelemetry instruments used by the hybrid orchestrator: counters for peer attribution and fallback events, a histogram for per-strategy health, an up-down counter for the active-strategy gauge, and span wrappers for `start()` and `discover()`. All instruments are namespaced under `@suaveplan/network-discovery-hybrid` so consumers can pivot dashboards by tracer / meter name.
|
||||
|
||||
Every public method in the orchestrator threads its observability through this module so the wire surface is uniform and easy to test (each instrument is a thin pass-through to the global OTel `meter` and `tracer`).
|
||||
|
||||
## Features
|
||||
|
||||
- `hybrid.peer_sources_total{strategy}` counter — incremented when a strategy contributes peers to the deduped set.
|
||||
- `hybrid.strategy_health_score{strategy}` histogram — recorded after every `discover()` call with the rolling 0..1 success rate.
|
||||
- `hybrid.fallback_total{from,to}` counter — recorded every time the orchestrator promotes a fallback past a degraded primary.
|
||||
- `hybrid.active_strategies{strategy}` up-down counter — `+1` on activate, `-1` on deactivate; net gauge of currently-active strategies.
|
||||
- `hybrid.discover_duration_ms{outcome}` histogram — wall-clock duration of `discover()` calls with outcome attribute (`ok` | `empty` | `error` | `timeout`).
|
||||
- `withStartSpan` / `withDiscoverSpan` wrappers for span management.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordPeerSource,
|
||||
recordStrategyHealth,
|
||||
} from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
recordPeerSource("peerjs", 3);
|
||||
recordStrategyHealth("peerjs", 0.95);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordFallback,
|
||||
recordActiveStrategyDelta,
|
||||
withDiscoverSpan,
|
||||
recordDiscoverDuration,
|
||||
} from "@suaveplan/network-discovery-hybrid";
|
||||
|
||||
recordActiveStrategyDelta("trystero", 1);
|
||||
recordFallback("peerjs", "trystero");
|
||||
|
||||
await withDiscoverSpan(async (_span) => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
// ... discovery work
|
||||
recordDiscoverDuration(performance.now() - start, "ok");
|
||||
} catch (e) {
|
||||
recordDiscoverDuration(performance.now() - start, "error");
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordPeerSource(strategy, count)` — increments `hybrid.peer_sources_total` by `count`. No-op when `count <= 0`.
|
||||
- `recordStrategyHealth(strategy, score)` — records the rolling health score (0..1) for `strategy`.
|
||||
- `recordFallback(from, to)` — increments `hybrid.fallback_total` with `{from, to}` attributes.
|
||||
- `recordActiveStrategyDelta(strategy, delta)` — `delta ∈ {1, -1}`; tracks active-strategy gauge.
|
||||
- `recordDiscoverDuration(ms, outcome)` — records `hybrid.discover_duration_ms` with the `outcome` attribute.
|
||||
- `withStartSpan(fn)` — wraps `fn` in an OTel `hybrid.start` span.
|
||||
- `withDiscoverSpan(fn)` — wraps `fn` in an OTel `hybrid.discover` span.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The counters intentionally accept low-cardinality attributes only (`strategy` is one of six known IDs; `outcome` is one of four). High-cardinality data (per-peer ids, error messages) is never recorded — those go to span attributes, not metric attributes, to avoid blowing up the meter cardinality budget.
|
||||
|
||||
`recordActiveStrategyDelta` uses an `UpDownCounter` rather than a synchronous gauge so callers do not have to maintain a parallel observable-gauge callback. The orchestrator emits exactly one `+1` per activate and exactly one `-1` per deactivate, so the gauge stays accurate as long as those pair up — the lifecycle FSM in `lifecycle.ts` enforces that pairing.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Hybrid Discovery Types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the universal types consumed across the `@suaveplan/network-discovery-hybrid` package. The hybrid manager composes any subset of the five Wave-2 sibling discovery strategies behind a single `DiscoveryStrategy` contract from `@suaveplan/network-bootstrap`. This file is the single source of truth for the public surface of configuration, status, health, peer provenance, and bus-event payloads.
|
||||
|
||||
The types are intentionally framework-free and depend only on `@suaveplan/network-bootstrap` and `@suaveplan/types`. Concrete strategy implementations live in sibling packages and are pulled in lazily via the `StrategyLoader` so the universal consumer never pays for a strategy it does not register.
|
||||
|
||||
## Features
|
||||
|
||||
- `StrategyId` union covers the five shipped Wave-2 strategies plus a `"custom"` slot for caller-supplied strategies.
|
||||
- `StrategyPriority` three-bucket model: `primary` / `fallback` / `backup`.
|
||||
- `StrategyConfig` accepts either a pre-built `strategy` or a lazy `factory` so optional peer-deps can be resolved on demand.
|
||||
- `HybridDiscoveryConfig` exposes `loadBalancing` (`priority` / `weighted` / `fastest`), `autoFallback`, `deduplication`, and tunable timeouts.
|
||||
- `StrategyHealth` tracks active state, peer count, rolling success rate, consecutive failures, and last error.
|
||||
- `PeerSource` records the de-duplicated peer with first-seen + last-seen provenance plus the ordered set of strategies that have observed it.
|
||||
- `HybridDiscoveryEventMap` typed for use with `EventBusLike` from `@suaveplan/events`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { HybridDiscoveryConfig } from "@suaveplan/network-discovery-hybrid/types";
|
||||
|
||||
const config: HybridDiscoveryConfig = {
|
||||
strategy: "hybrid",
|
||||
strategies: [
|
||||
{ id: "peerjs", priority: "primary" },
|
||||
{ id: "trystero", priority: "fallback" },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
HybridDiscoveryConfig,
|
||||
StrategyHealth,
|
||||
} from "@suaveplan/network-discovery-hybrid/types";
|
||||
|
||||
const config: HybridDiscoveryConfig = {
|
||||
strategy: "hybrid",
|
||||
loadBalancing: "weighted",
|
||||
autoFallback: true,
|
||||
deduplication: true,
|
||||
discoveryTimeoutMs: 8000,
|
||||
strategies: [
|
||||
{ id: "peerjs", priority: "primary", weight: 70, timeoutMs: 4000 },
|
||||
{ id: "trystero", priority: "fallback", weight: 30 },
|
||||
{ id: "gundb", priority: "backup", weight: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
function summarise(h: StrategyHealth): string {
|
||||
return `${h.label}: ${h.active ? "active" : "idle"} (${h.peerCount} peers)`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StrategyId` — `'peerjs' | 'trystero' | 'webtorrent' | 'crdt' | 'gundb' | 'custom'`.
|
||||
- `StrategyPriority` — `'primary' | 'fallback' | 'backup'`.
|
||||
- `StrategyConfig` — `{ id, priority, label?, weight?, timeoutMs?, strategy?, factory? }`. Either `strategy` or `factory` must be present (enforced by the schema).
|
||||
- `HybridDiscoveryConfig` — top-level config. `strategies` is non-empty; all numeric tunables are optional with defaults.
|
||||
- `ResolvedHybridDiscoveryConfig` — every optional field is materialised.
|
||||
- `StrategyHealth` — per-strategy snapshot with `healthy`, `active`, `peerCount`, `lastDiscoverMs`, `successRate`, `consecutiveFailures`, `lastError?`, `lastCheck`.
|
||||
- `PeerSource` — `{ peerId, discoveredBy, primaryStrategy, firstSeen, lastSeen, peer }`. `discoveredBy` is append-only and tracks every strategy that has seen the peer.
|
||||
- `HybridManagerStatus` — `{ running, activeStrategies, totalPeers, peersByStrategy, strategies, loadBalancing }`.
|
||||
- `HybridDiscoveryEventMap` — bus events keyed by topic. Use with `EventBusLike` from `@suaveplan/events`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `StrategyConfig.strategy | factory` split is intentional. Tests register pre-built strategies directly; production code uses `factory` so the hybrid orchestrator can resolve a missing peer-dep with `STRATEGY_LOAD_FAILED` instead of crashing at module-evaluation time. The `clock` parameter on `HybridDiscoveryConfig` is a `() => number` per `@suaveplan/types` and is threaded into every timestamp-bearing call site so fast-check property tests can replay deterministic timelines.
|
||||
|
||||
`PeerSource.discoveredBy` is ordered — the first entry is always the `primaryStrategy`. When a strategy loses a peer, the strategy is filtered out of `discoveredBy`; if no strategies remain, the peer is removed entirely and `hybrid:peer-lost` fires. This matches the upstream behaviour while making provenance explicit at the type level.
|
||||
Reference in New Issue
Block a user