@hashtree/worker 0.2.0 → 0.2.2
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/package.json +7 -3
- package/src/app-runtime.ts +393 -0
- package/src/capabilities/blossomBandwidthTracker.ts +74 -0
- package/src/capabilities/blossomTransport.ts +179 -0
- package/src/capabilities/connectivity.ts +54 -0
- package/src/capabilities/idbStorage.ts +94 -0
- package/src/capabilities/meshRouterStore.ts +426 -0
- package/src/capabilities/rootResolver.ts +497 -0
- package/src/client-id.ts +137 -0
- package/src/client.ts +501 -0
- package/src/entry.ts +3 -0
- package/src/htree-path.ts +53 -0
- package/src/htree-url.ts +156 -0
- package/src/index.ts +76 -0
- package/src/mediaStreaming.ts +64 -0
- package/src/p2p/boundedQueue.ts +168 -0
- package/src/p2p/errorMessage.ts +6 -0
- package/src/p2p/index.ts +48 -0
- package/src/p2p/lruCache.ts +78 -0
- package/src/p2p/meshQueryRouter.ts +361 -0
- package/src/p2p/protocol.ts +11 -0
- package/src/p2p/queryForwardingMachine.ts +197 -0
- package/src/p2p/signaling.ts +284 -0
- package/src/p2p/uploadRateLimiter.ts +85 -0
- package/src/p2p/webrtcController.ts +1168 -0
- package/src/p2p/webrtcProxy.ts +519 -0
- package/src/privacyGuards.ts +31 -0
- package/src/protocol.ts +124 -0
- package/src/relay/identity.ts +86 -0
- package/src/relay/mediaHandler.ts +1633 -0
- package/src/relay/ndk.ts +590 -0
- package/src/relay/nostr-wasm.ts +249 -0
- package/src/relay/nostr.ts +249 -0
- package/src/relay/protocol.ts +361 -0
- package/src/relay/publicAssetUrl.ts +25 -0
- package/src/relay/rootPathResolver.ts +50 -0
- package/src/relay/shims.d.ts +17 -0
- package/src/relay/signing.ts +332 -0
- package/src/relay/treeRootCache.ts +354 -0
- package/src/relay/treeRootSubscription.ts +577 -0
- package/src/relay/utils/constants.ts +139 -0
- package/src/relay/utils/errorMessage.ts +7 -0
- package/src/relay/utils/lruCache.ts +79 -0
- package/src/relay/webrtc.ts +5 -0
- package/src/relay/webrtcSignaling.ts +108 -0
- package/src/relay/worker.ts +1787 -0
- package/src/relay-client.ts +265 -0
- package/src/relay-entry.ts +1 -0
- package/src/runtime-network.ts +134 -0
- package/src/runtime.ts +153 -0
- package/src/transferableBytes.ts +5 -0
- package/src/tree-root.ts +851 -0
- package/src/types.ts +8 -0
- package/src/worker.ts +975 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BlossomServerConfig, ConnectivityState } from '../protocol.js';
|
|
2
|
+
|
|
3
|
+
const PROBE_TIMEOUT_MS = 3500;
|
|
4
|
+
const DUMMY_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
5
|
+
|
|
6
|
+
function stripTrailingSlash(url: string): string {
|
|
7
|
+
return url.replace(/\/+$/, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function probe(url: string): Promise<boolean> {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
method: 'HEAD',
|
|
16
|
+
// Connectivity checks should not fail just because cross-origin response
|
|
17
|
+
// headers are restricted. If the fetch resolves in no-cors mode, the
|
|
18
|
+
// endpoint is reachable.
|
|
19
|
+
mode: 'no-cors',
|
|
20
|
+
signal: controller.signal,
|
|
21
|
+
cache: 'no-store',
|
|
22
|
+
});
|
|
23
|
+
if (res.type === 'opaque') {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return res.status >= 100;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
} finally {
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function probeConnectivity(servers: BlossomServerConfig[]): Promise<ConnectivityState> {
|
|
35
|
+
const readServers = servers.filter(server => server.read !== false);
|
|
36
|
+
const writeServers = servers.filter(server => server.write);
|
|
37
|
+
|
|
38
|
+
const [readResults, writeResults] = await Promise.all([
|
|
39
|
+
Promise.all(readServers.map(server => probe(`${stripTrailingSlash(server.url)}/${DUMMY_HASH}.bin`))),
|
|
40
|
+
Promise.all(writeServers.map(server => probe(`${stripTrailingSlash(server.url)}/upload`))),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const reachableReadServers = readResults.filter(Boolean).length;
|
|
44
|
+
const reachableWriteServers = writeResults.filter(Boolean).length;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
online: typeof navigator === 'undefined' ? true : navigator.onLine,
|
|
48
|
+
reachableReadServers,
|
|
49
|
+
totalReadServers: readServers.length,
|
|
50
|
+
reachableWriteServers,
|
|
51
|
+
totalWriteServers: writeServers.length,
|
|
52
|
+
updatedAt: Date.now(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { fromHex, sha256, toHex } from '@hashtree/core';
|
|
2
|
+
import { DexieStore } from '@hashtree/dexie';
|
|
3
|
+
|
|
4
|
+
export interface StorageStats {
|
|
5
|
+
items: number;
|
|
6
|
+
bytes: number;
|
|
7
|
+
maxBytes: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class IdbBlobStorage {
|
|
11
|
+
private readonly store: DexieStore;
|
|
12
|
+
private maxBytes: number;
|
|
13
|
+
private writesSinceEviction = 0;
|
|
14
|
+
private evictionPromise: Promise<void> | null = null;
|
|
15
|
+
|
|
16
|
+
private static readonly EVICTION_WRITE_INTERVAL = 32;
|
|
17
|
+
|
|
18
|
+
constructor(dbName: string, maxBytes: number) {
|
|
19
|
+
this.store = new DexieStore(dbName);
|
|
20
|
+
this.maxBytes = maxBytes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setMaxBytes(maxBytes: number): void {
|
|
24
|
+
this.maxBytes = maxBytes;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getMaxBytes(): number {
|
|
28
|
+
return this.maxBytes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async put(data: Uint8Array): Promise<string> {
|
|
32
|
+
const hashHex = toHex(await sha256(data));
|
|
33
|
+
await this.store.put(fromHex(hashHex), data);
|
|
34
|
+
void this.scheduleEviction();
|
|
35
|
+
return hashHex;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async putByHash(hashHex: string, data: Uint8Array): Promise<void> {
|
|
39
|
+
const computed = toHex(await sha256(data));
|
|
40
|
+
if (computed !== hashHex) {
|
|
41
|
+
throw new Error('Hash mismatch while caching fetched blob');
|
|
42
|
+
}
|
|
43
|
+
await this.store.put(fromHex(hashHex), data);
|
|
44
|
+
void this.scheduleEviction();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async putByHashTrusted(hashHex: string, data: Uint8Array): Promise<void> {
|
|
48
|
+
await this.store.put(fromHex(hashHex), data);
|
|
49
|
+
void this.scheduleEviction();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async get(hashHex: string): Promise<Uint8Array | null> {
|
|
53
|
+
return this.store.get(fromHex(hashHex));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async has(hashHex: string): Promise<boolean> {
|
|
57
|
+
return this.store.has(fromHex(hashHex));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async delete(hashHex: string): Promise<boolean> {
|
|
61
|
+
return this.store.delete(fromHex(hashHex));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getStats(): Promise<StorageStats> {
|
|
65
|
+
const [items, bytes] = await Promise.all([
|
|
66
|
+
this.store.count(),
|
|
67
|
+
this.store.totalBytes(),
|
|
68
|
+
]);
|
|
69
|
+
return { items, bytes, maxBytes: this.maxBytes };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close(): void {
|
|
73
|
+
this.store.close();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private scheduleEviction(): void {
|
|
77
|
+
this.writesSinceEviction += 1;
|
|
78
|
+
if (this.writesSinceEviction < IdbBlobStorage.EVICTION_WRITE_INTERVAL) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.writesSinceEviction = 0;
|
|
82
|
+
|
|
83
|
+
if (this.evictionPromise) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.evictionPromise = this.store
|
|
88
|
+
.evict(this.maxBytes)
|
|
89
|
+
.then(() => {})
|
|
90
|
+
.finally(() => {
|
|
91
|
+
this.evictionPromise = null;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import type { Hash, Store } from '@hashtree/core';
|
|
2
|
+
import { toHex } from '@hashtree/core';
|
|
3
|
+
import {
|
|
4
|
+
buildHedgedWavePlan,
|
|
5
|
+
normalizeDispatchConfig,
|
|
6
|
+
type RequestDispatchConfig,
|
|
7
|
+
} from '@hashtree/nostr';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_DISPATCH: RequestDispatchConfig = {
|
|
10
|
+
initialFanout: 1,
|
|
11
|
+
hedgeFanout: 1,
|
|
12
|
+
maxFanout: 4,
|
|
13
|
+
hedgeIntervalMs: 75,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_500;
|
|
17
|
+
const INITIAL_BACKOFF_MS = 250;
|
|
18
|
+
const MAX_BACKOFF_MS = 10_000;
|
|
19
|
+
const SCORE_TIE_DELTA = 0.15;
|
|
20
|
+
|
|
21
|
+
export interface MeshReadSource {
|
|
22
|
+
id: string;
|
|
23
|
+
get(hash: Hash): Promise<Uint8Array | null>;
|
|
24
|
+
isAvailable?: () => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MeshRouterGetOptions {
|
|
28
|
+
sourceIds?: readonly string[];
|
|
29
|
+
skipPrimary?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MeshRouterGetResult {
|
|
33
|
+
data: Uint8Array;
|
|
34
|
+
sourceId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MeshRouterStoreConfig {
|
|
38
|
+
primary: Store;
|
|
39
|
+
sources?: MeshReadSource[];
|
|
40
|
+
dispatch?: RequestDispatchConfig;
|
|
41
|
+
requestTimeoutMs?: number;
|
|
42
|
+
primarySourceId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SourceStats {
|
|
46
|
+
requests: number;
|
|
47
|
+
successes: number;
|
|
48
|
+
misses: number;
|
|
49
|
+
failures: number;
|
|
50
|
+
timeouts: number;
|
|
51
|
+
srttMs: number;
|
|
52
|
+
rttvarMs: number;
|
|
53
|
+
backoffLevel: number;
|
|
54
|
+
backedOffUntilMs?: number;
|
|
55
|
+
lastSuccessMs?: number;
|
|
56
|
+
lastFailureMs?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface InFlightSourceRequest {
|
|
60
|
+
source: MeshReadSource;
|
|
61
|
+
settled: boolean;
|
|
62
|
+
timeoutRecorded: boolean;
|
|
63
|
+
promise: Promise<{ sourceId: string; data: Uint8Array | null }>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function defaultStats(): SourceStats {
|
|
67
|
+
return {
|
|
68
|
+
requests: 0,
|
|
69
|
+
successes: 0,
|
|
70
|
+
misses: 0,
|
|
71
|
+
failures: 0,
|
|
72
|
+
timeouts: 0,
|
|
73
|
+
srttMs: 0,
|
|
74
|
+
rttvarMs: 0,
|
|
75
|
+
backoffLevel: 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reliabilityScore(stats: SourceStats): number {
|
|
80
|
+
return (stats.successes + 1) / (stats.requests + 2);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function latencyScore(stats: SourceStats): number {
|
|
84
|
+
if (stats.srttMs <= 0) return 0.5;
|
|
85
|
+
return Math.min(1, 500 / (stats.srttMs + 50));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasHistory(stats: SourceStats): boolean {
|
|
89
|
+
return stats.requests > 0 || stats.successes > 0 || stats.misses > 0 || stats.failures > 0 || stats.timeouts > 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scoreSource(stats: SourceStats, now: number): number {
|
|
93
|
+
if (stats.backedOffUntilMs && stats.backedOffUntilMs > now) {
|
|
94
|
+
return Number.NEGATIVE_INFINITY;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const missPenalty = stats.requests > 0 ? (stats.misses / stats.requests) * 0.15 : 0;
|
|
98
|
+
const failurePenalty = stats.requests > 0 ? ((stats.failures + stats.timeouts) / stats.requests) * 0.3 : 0;
|
|
99
|
+
const recencyBonus =
|
|
100
|
+
stats.lastSuccessMs && now - stats.lastSuccessMs < 60_000
|
|
101
|
+
? 0.1
|
|
102
|
+
: 0;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
0.6 * reliabilityScore(stats) +
|
|
106
|
+
0.3 * latencyScore(stats) +
|
|
107
|
+
recencyBonus -
|
|
108
|
+
missPenalty -
|
|
109
|
+
failurePenalty
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class MeshRouterStore implements Store {
|
|
114
|
+
private readonly primary: Store;
|
|
115
|
+
private readonly primarySourceId: string;
|
|
116
|
+
private readonly dispatch: RequestDispatchConfig;
|
|
117
|
+
private readonly requestTimeoutMs: number;
|
|
118
|
+
private readonly sources = new Map<string, MeshReadSource>();
|
|
119
|
+
private readonly statsBySource = new Map<string, SourceStats>();
|
|
120
|
+
private readonly inflightReads = new Map<string, Promise<MeshRouterGetResult | null>>();
|
|
121
|
+
|
|
122
|
+
constructor(config: MeshRouterStoreConfig) {
|
|
123
|
+
this.primary = config.primary;
|
|
124
|
+
this.primarySourceId = config.primarySourceId ?? 'primary';
|
|
125
|
+
this.dispatch = config.dispatch ?? DEFAULT_DISPATCH;
|
|
126
|
+
this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
127
|
+
this.setSources(config.sources ?? []);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
setSources(sources: MeshReadSource[]): void {
|
|
131
|
+
this.sources.clear();
|
|
132
|
+
for (const source of sources) {
|
|
133
|
+
this.sources.set(source.id, source);
|
|
134
|
+
this.statsBySource.set(source.id, this.statsBySource.get(source.id) ?? defaultStats());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
addSource(source: MeshReadSource): void {
|
|
139
|
+
this.sources.set(source.id, source);
|
|
140
|
+
this.statsBySource.set(source.id, this.statsBySource.get(source.id) ?? defaultStats());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
removeSource(sourceId: string): void {
|
|
144
|
+
this.sources.delete(sourceId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getDetailed(hash: Hash, options: MeshRouterGetOptions = {}): Promise<MeshRouterGetResult | null> {
|
|
148
|
+
if (!options.skipPrimary) {
|
|
149
|
+
const local = await this.primary.get(hash);
|
|
150
|
+
if (local) {
|
|
151
|
+
return { data: local, sourceId: this.primarySourceId };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pendingKey = this.pendingReadKey(hash, options);
|
|
156
|
+
let pending = this.inflightReads.get(pendingKey);
|
|
157
|
+
if (!pending) {
|
|
158
|
+
pending = this.loadFromSources(hash, options).finally(() => {
|
|
159
|
+
if (this.inflightReads.get(pendingKey) === pending) {
|
|
160
|
+
this.inflightReads.delete(pendingKey);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
this.inflightReads.set(pendingKey, pending);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return pending;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getSourceStats(): Record<string, SourceStats> {
|
|
170
|
+
return Object.fromEntries(
|
|
171
|
+
Array.from(this.statsBySource.entries()).map(([sourceId, stats]) => [sourceId, { ...stats }]),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async put(hash: Hash, data: Uint8Array): Promise<boolean> {
|
|
176
|
+
return this.primary.put(hash, data);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async get(hash: Hash): Promise<Uint8Array | null> {
|
|
180
|
+
return (await this.getDetailed(hash))?.data ?? null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async has(hash: Hash): Promise<boolean> {
|
|
184
|
+
return this.primary.has(hash);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async delete(hash: Hash): Promise<boolean> {
|
|
188
|
+
return this.primary.delete(hash);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private pendingReadKey(hash: Hash, options: MeshRouterGetOptions): string {
|
|
192
|
+
const sourceKey = options.sourceIds && options.sourceIds.length > 0
|
|
193
|
+
? [...options.sourceIds].sort().join(',')
|
|
194
|
+
: '*';
|
|
195
|
+
return `${toHex(hash)}:${options.skipPrimary === true ? 'skip-primary' : 'with-primary'}:${sourceKey}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private getCandidateSources(sourceIds?: readonly string[]): MeshReadSource[] {
|
|
199
|
+
const requested = sourceIds && sourceIds.length > 0
|
|
200
|
+
? new Set(sourceIds)
|
|
201
|
+
: null;
|
|
202
|
+
const available = Array.from(this.sources.values()).filter((source) => {
|
|
203
|
+
if (requested && !requested.has(source.id)) return false;
|
|
204
|
+
return source.isAvailable ? source.isAvailable() : true;
|
|
205
|
+
});
|
|
206
|
+
if (available.length === 0) return [];
|
|
207
|
+
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const healthy = available.filter((source) => {
|
|
210
|
+
const stats = this.statsBySource.get(source.id) ?? defaultStats();
|
|
211
|
+
return !stats.backedOffUntilMs || stats.backedOffUntilMs <= now;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return healthy.length > 0 ? healthy : available;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private orderedSources(sourceIds?: readonly string[]): MeshReadSource[] {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const candidates = this.getCandidateSources(sourceIds);
|
|
220
|
+
return candidates.sort((left, right) => {
|
|
221
|
+
const leftStats = this.statsBySource.get(left.id) ?? defaultStats();
|
|
222
|
+
const rightStats = this.statsBySource.get(right.id) ?? defaultStats();
|
|
223
|
+
const scoreDiff = scoreSource(rightStats, now) - scoreSource(leftStats, now);
|
|
224
|
+
if (scoreDiff !== 0) return scoreDiff;
|
|
225
|
+
return left.id.localeCompare(right.id);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private shouldProbeMultipleSources(orderedSources: MeshReadSource[]): boolean {
|
|
230
|
+
if (orderedSources.length <= 1) return false;
|
|
231
|
+
|
|
232
|
+
const [best, secondBest] = orderedSources;
|
|
233
|
+
const bestStats = this.statsBySource.get(best.id) ?? defaultStats();
|
|
234
|
+
const secondStats = this.statsBySource.get(secondBest.id) ?? defaultStats();
|
|
235
|
+
if (!hasHistory(bestStats) || !hasHistory(secondStats)) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const diff = scoreSource(bestStats, now) - scoreSource(secondStats, now);
|
|
241
|
+
return diff < SCORE_TIE_DELTA;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private dispatchFor(sourceCount: number, orderedSources: MeshReadSource[]): RequestDispatchConfig {
|
|
245
|
+
const probeMultiple = this.shouldProbeMultipleSources(orderedSources);
|
|
246
|
+
const initialFanout = probeMultiple
|
|
247
|
+
? Math.min(sourceCount, 2)
|
|
248
|
+
: 1;
|
|
249
|
+
return {
|
|
250
|
+
initialFanout,
|
|
251
|
+
hedgeFanout: this.dispatch.hedgeFanout,
|
|
252
|
+
maxFanout: Math.min(this.dispatch.maxFanout, sourceCount),
|
|
253
|
+
hedgeIntervalMs: this.dispatch.hedgeIntervalMs,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private createInFlightSourceRequest(source: MeshReadSource, hash: Hash): InFlightSourceRequest {
|
|
258
|
+
const startedAt = Date.now();
|
|
259
|
+
this.recordRequest(source.id);
|
|
260
|
+
|
|
261
|
+
const task: InFlightSourceRequest = {
|
|
262
|
+
source,
|
|
263
|
+
settled: false,
|
|
264
|
+
timeoutRecorded: false,
|
|
265
|
+
promise: Promise.resolve({ sourceId: source.id, data: null }),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
task.promise = source.get(hash)
|
|
269
|
+
.then(async (data) => {
|
|
270
|
+
const elapsedMs = Math.max(1, Date.now() - startedAt);
|
|
271
|
+
if (data) {
|
|
272
|
+
this.recordSuccess(source.id, elapsedMs);
|
|
273
|
+
await this.primary.put(hash, data).catch(() => false);
|
|
274
|
+
return { sourceId: source.id, data };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!task.timeoutRecorded) {
|
|
278
|
+
this.recordMiss(source.id);
|
|
279
|
+
}
|
|
280
|
+
return { sourceId: source.id, data: null };
|
|
281
|
+
})
|
|
282
|
+
.catch(() => {
|
|
283
|
+
if (!task.timeoutRecorded) {
|
|
284
|
+
this.recordFailure(source.id);
|
|
285
|
+
}
|
|
286
|
+
return { sourceId: source.id, data: null };
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return task;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async waitForNextResult(
|
|
293
|
+
inFlight: InFlightSourceRequest[],
|
|
294
|
+
waitMs: number,
|
|
295
|
+
): Promise<{ task: InFlightSourceRequest; sourceId: string; data: Uint8Array | null } | null> {
|
|
296
|
+
const active = inFlight.filter((task) => !task.settled);
|
|
297
|
+
if (active.length === 0 || waitMs <= 0) return null;
|
|
298
|
+
|
|
299
|
+
const timeout = new Promise<null>((resolve) => {
|
|
300
|
+
setTimeout(() => resolve(null), waitMs);
|
|
301
|
+
});
|
|
302
|
+
const result = await Promise.race([
|
|
303
|
+
timeout,
|
|
304
|
+
...active.map((task) => task.promise.then((value) => ({ task, ...value }))),
|
|
305
|
+
]);
|
|
306
|
+
if (!result) return null;
|
|
307
|
+
|
|
308
|
+
result.task.settled = true;
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async loadFromSources(hash: Hash, options: MeshRouterGetOptions): Promise<MeshRouterGetResult | null> {
|
|
313
|
+
const orderedSources = this.orderedSources(options.sourceIds);
|
|
314
|
+
if (orderedSources.length === 0) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const dispatch = normalizeDispatchConfig(
|
|
319
|
+
this.dispatchFor(orderedSources.length, orderedSources),
|
|
320
|
+
orderedSources.length,
|
|
321
|
+
);
|
|
322
|
+
const wavePlan = buildHedgedWavePlan(orderedSources.length, dispatch);
|
|
323
|
+
if (wavePlan.length === 0) return null;
|
|
324
|
+
|
|
325
|
+
const deadline = Date.now() + this.requestTimeoutMs;
|
|
326
|
+
const inFlight: InFlightSourceRequest[] = [];
|
|
327
|
+
let nextSourceIdx = 0;
|
|
328
|
+
|
|
329
|
+
for (let waveIdx = 0; waveIdx < wavePlan.length; waveIdx++) {
|
|
330
|
+
const waveSize = wavePlan[waveIdx];
|
|
331
|
+
const from = nextSourceIdx;
|
|
332
|
+
const to = Math.min(from + waveSize, orderedSources.length);
|
|
333
|
+
nextSourceIdx = to;
|
|
334
|
+
|
|
335
|
+
for (const source of orderedSources.slice(from, to)) {
|
|
336
|
+
inFlight.push(this.createInFlightSourceRequest(source, hash));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const isLastWave = waveIdx === wavePlan.length - 1 || nextSourceIdx >= orderedSources.length;
|
|
340
|
+
const windowEnd = isLastWave
|
|
341
|
+
? deadline
|
|
342
|
+
: Math.min(deadline, Date.now() + dispatch.hedgeIntervalMs);
|
|
343
|
+
|
|
344
|
+
while (Date.now() < windowEnd) {
|
|
345
|
+
const remaining = windowEnd - Date.now();
|
|
346
|
+
const result = await this.waitForNextResult(inFlight, remaining);
|
|
347
|
+
if (!result) break;
|
|
348
|
+
if (result.data) {
|
|
349
|
+
return {
|
|
350
|
+
data: result.data,
|
|
351
|
+
sourceId: result.sourceId,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (Date.now() >= deadline) {
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const task of inFlight) {
|
|
362
|
+
if (task.settled) continue;
|
|
363
|
+
task.timeoutRecorded = true;
|
|
364
|
+
this.recordTimeout(task.source.id);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private statsFor(sourceId: string): SourceStats {
|
|
371
|
+
const stats = this.statsBySource.get(sourceId);
|
|
372
|
+
if (stats) return stats;
|
|
373
|
+
const created = defaultStats();
|
|
374
|
+
this.statsBySource.set(sourceId, created);
|
|
375
|
+
return created;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private recordRequest(sourceId: string): void {
|
|
379
|
+
this.statsFor(sourceId).requests += 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private recordMiss(sourceId: string): void {
|
|
383
|
+
this.statsFor(sourceId).misses += 1;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private recordSuccess(sourceId: string, elapsedMs: number): void {
|
|
387
|
+
const stats = this.statsFor(sourceId);
|
|
388
|
+
const now = Date.now();
|
|
389
|
+
stats.successes += 1;
|
|
390
|
+
stats.lastSuccessMs = now;
|
|
391
|
+
stats.backoffLevel = 0;
|
|
392
|
+
stats.backedOffUntilMs = undefined;
|
|
393
|
+
|
|
394
|
+
if (stats.srttMs === 0) {
|
|
395
|
+
stats.srttMs = elapsedMs;
|
|
396
|
+
stats.rttvarMs = elapsedMs / 2;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
stats.rttvarMs = 0.75 * stats.rttvarMs + 0.25 * Math.abs(stats.srttMs - elapsedMs);
|
|
401
|
+
stats.srttMs = 0.875 * stats.srttMs + 0.125 * elapsedMs;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private recordFailure(sourceId: string): void {
|
|
405
|
+
const stats = this.statsFor(sourceId);
|
|
406
|
+
stats.failures += 1;
|
|
407
|
+
stats.lastFailureMs = Date.now();
|
|
408
|
+
this.applyBackoff(stats);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private recordTimeout(sourceId: string): void {
|
|
412
|
+
const stats = this.statsFor(sourceId);
|
|
413
|
+
stats.timeouts += 1;
|
|
414
|
+
stats.lastFailureMs = Date.now();
|
|
415
|
+
this.applyBackoff(stats);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private applyBackoff(stats: SourceStats): void {
|
|
419
|
+
stats.backoffLevel += 1;
|
|
420
|
+
const backoffMs = Math.min(
|
|
421
|
+
MAX_BACKOFF_MS,
|
|
422
|
+
INITIAL_BACKOFF_MS * (2 ** Math.max(0, stats.backoffLevel - 1)),
|
|
423
|
+
);
|
|
424
|
+
stats.backedOffUntilMs = Date.now() + backoffMs;
|
|
425
|
+
}
|
|
426
|
+
}
|