Skip to content

Commit f942aa3

Browse files
authored
fix(tsp): cache sender resolution on the inbound path (#137)
An inbound TSP frame is awaited by the transport before it acks and before it takes the next frame (R1.6), so whatever the handler does happens serially on the one socket that also carries replies. `unpackInboundTsp` resolves the sender's DID there — up to two network round-trips **per frame**, uncached. A redelivery burst turns that into hundreds of serial fetches while an in-flight request waits behind them. That is not hypothetical: a client that starts acking a backlog it had never acked gets exactly one such burst on its first connect, and a TSP reply timeout is a hard failure with no fallback, so the request in flight does not degrade — it fails. The observed symptom was a vault list that would not load over TSP immediately after an upgrade, and worked once the backlog had drained. Caching collapses a burst from one resolution per frame to one per peer. TTL'd rather than invalidated on failure. Eviction would need the unpack result plumbed back through a resolver that cannot see it, and redelivery already supplies the retry: a frame refused against a stale key is redelivered — the ack is withheld precisely because the handler threw — so a rotation costs at most one TTL of refusals on a message that was going to be re-sent anyway. Bounded, so a chatty socket cannot grow it without limit. The cache is split over an injected resolver so it is testable as itself. The obvious alternative — a test re-implementing the TTL and the bound and asserting against its own copy — passes whatever the real policy does, which is the one thing a cache test must not do. The bound is likewise asserted through behaviour (the newest entry still hits, the oldest re-resolves) rather than by exporting the Map. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
1 parent 988f5df commit f942aa3

3 files changed

Lines changed: 166 additions & 1 deletion

File tree

packages/core/src/vta/tsp-vid.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,77 @@ export async function resolveTspEndpoint(
176176
export function resolveVtaTspEndpoint(vtaDid: string): Promise<TspRemoteEndpoint> {
177177
return resolveTspEndpoint(vtaDid, resolveDidDocument);
178178
}
179+
180+
/** How long a resolved endpoint is reused. Short enough that a key rotation
181+
* heals on its own — an inbound frame is redelivered until acked, so a frame
182+
* refused against a stale key succeeds on a later delivery once this lapses —
183+
* and long enough that a delivery burst resolves once rather than per frame. */
184+
const ENDPOINT_TTL_MS = 5 * 60_000;
185+
/** Bound: one entry per peer, and a wallet talks to a handful. */
186+
const ENDPOINT_CACHE_MAX = 32;
187+
188+
const endpointCache = new Map<string, { at: number; endpoint: TspRemoteEndpoint }>();
189+
190+
/**
191+
* {@link resolveVtaTspEndpoint} with a short-lived cache.
192+
*
193+
* For the **inbound** path this is not an optimisation, it is what keeps the
194+
* socket moving. An inbound TSP frame is awaited by the transport before it
195+
* acks (R1.6) and before it takes the next frame, so whatever happens per
196+
* frame is serial on the one socket that also carries replies. Resolving a DID
197+
* there costs up to two network round-trips *each*, and a redelivery burst —
198+
* guaranteed on the first connect after a client starts acking a backlog it
199+
* had never acked — turns that into hundreds of serial fetches while an
200+
* in-flight request waits behind them. A TSP reply timeout is a hard failure
201+
* with no fallback, so the request does not degrade, it fails.
202+
*
203+
* Caching collapses a burst from one resolution per frame to one per peer.
204+
*
205+
* Deliberately TTL'd rather than invalidated on failure: eviction would need
206+
* the unpack result plumbed back through a resolver that cannot see it, and
207+
* redelivery already provides the retry. A rotation costs at most `TTL` of
208+
* refusals for a frame that is being redelivered anyway.
209+
*/
210+
export function resolveVtaTspEndpointCached(vid: string): Promise<TspRemoteEndpoint> {
211+
return resolveTspEndpointCachedWith(vid, resolveVtaTspEndpoint);
212+
}
213+
214+
/**
215+
* The caching half of {@link resolveVtaTspEndpointCached}, over an injected
216+
* resolver.
217+
*
218+
* Split out so the cache policy is testable as itself. The obvious
219+
* alternative — a test that re-implements the TTL and the bound and asserts
220+
* against its own copy — passes whatever the real policy does, which is the
221+
* one thing a cache test must not do.
222+
*/
223+
export async function resolveTspEndpointCachedWith(
224+
vid: string,
225+
resolve: (vid: string) => Promise<TspRemoteEndpoint>,
226+
now: () => number = Date.now,
227+
): Promise<TspRemoteEndpoint> {
228+
const hit = endpointCache.get(vid);
229+
if (hit && now() - hit.at < ENDPOINT_TTL_MS) return hit.endpoint;
230+
231+
const endpoint = await resolve(vid);
232+
endpointCache.set(vid, { at: now(), endpoint });
233+
if (endpointCache.size > ENDPOINT_CACHE_MAX) {
234+
// Oldest insertion first — Map preserves it, and a wallet's peer set is
235+
// small enough that the eviction policy barely matters; the bound is what
236+
// matters.
237+
const oldest = endpointCache.keys().next().value;
238+
if (oldest !== undefined) endpointCache.delete(oldest);
239+
}
240+
return endpoint;
241+
}
242+
243+
/** The reuse window, exported so a test states the policy's own number rather
244+
* than a copy of it. */
245+
export const TSP_ENDPOINT_TTL_MS = ENDPOINT_TTL_MS;
246+
247+
/** Drop cached endpoints. For tests, and for a caller that knows a peer's keys
248+
* have moved and does not want to wait out the TTL. */
249+
export function clearTspEndpointCache(vid?: string): void {
250+
if (vid === undefined) endpointCache.clear();
251+
else endpointCache.delete(vid);
252+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// The inbound path resolves a sender per frame, serially, on the socket that
2+
// also carries replies — so a burst that resolves per frame starves an
3+
// in-flight request into a hard TSP timeout. These test the real cache, over
4+
// an injected resolver, rather than a re-implementation of its policy.
5+
6+
import { test } from "node:test";
7+
import assert from "node:assert/strict";
8+
9+
import {
10+
resolveTspEndpointCachedWith,
11+
clearTspEndpointCache,
12+
TSP_ENDPOINT_TTL_MS,
13+
} from "../dist/vta/index.js";
14+
15+
/** A resolver that counts calls, so "did the cache work" is observable. */
16+
function counting(endpointFor = (vid) => ({ vid })) {
17+
const state = { calls: 0 };
18+
return [
19+
async (vid) => {
20+
state.calls++;
21+
return endpointFor(vid);
22+
},
23+
state,
24+
];
25+
}
26+
27+
test("a burst of frames from one peer resolves that peer once", async () => {
28+
clearTspEndpointCache();
29+
const [resolve, state] = counting();
30+
// The shape of a redelivered backlog: many frames, one sender.
31+
for (let i = 0; i < 200; i++) {
32+
await resolveTspEndpointCachedWith("did:web:vta.example", resolve);
33+
}
34+
assert.equal(state.calls, 1, "200 frames must not be 200 DID resolutions");
35+
});
36+
37+
test("an entry lapses, so a rotated key heals without explicit eviction", async () => {
38+
clearTspEndpointCache();
39+
let key = "old";
40+
const [resolve] = counting(() => ({ key }));
41+
let clock = 0;
42+
const now = () => clock;
43+
44+
assert.equal((await resolveTspEndpointCachedWith("did:web:v.example", resolve, now)).key, "old");
45+
key = "new";
46+
// Inside the window the stale entry stands — and a frame refused against it
47+
// is redelivered rather than lost, which is what makes a TTL sufficient on
48+
// its own and eviction-on-failure unnecessary.
49+
clock = TSP_ENDPOINT_TTL_MS - 1;
50+
assert.equal((await resolveTspEndpointCachedWith("did:web:v.example", resolve, now)).key, "old");
51+
52+
clock = TSP_ENDPOINT_TTL_MS;
53+
assert.equal((await resolveTspEndpointCachedWith("did:web:v.example", resolve, now)).key, "new");
54+
});
55+
56+
test("clearing one peer leaves the others cached", async () => {
57+
clearTspEndpointCache();
58+
const [resolve, state] = counting();
59+
await resolveTspEndpointCachedWith("did:web:a.example", resolve);
60+
await resolveTspEndpointCachedWith("did:web:b.example", resolve);
61+
assert.equal(state.calls, 2);
62+
63+
clearTspEndpointCache("did:web:a.example");
64+
await resolveTspEndpointCachedWith("did:web:b.example", resolve);
65+
assert.equal(state.calls, 2, "b must still be cached");
66+
await resolveTspEndpointCachedWith("did:web:a.example", resolve);
67+
assert.equal(state.calls, 3, "a must have been dropped");
68+
});
69+
70+
test("the cache is bounded, so a chatty socket cannot grow it without limit", async () => {
71+
clearTspEndpointCache();
72+
const [resolve] = counting();
73+
for (let i = 0; i < 100; i++) {
74+
await resolveTspEndpointCachedWith(`did:web:peer-${i}.example`, resolve);
75+
}
76+
// Re-resolving the most recent peer must still be a hit; the oldest must have
77+
// been evicted. Asserted through behaviour rather than by reading the Map,
78+
// which is deliberately not exported.
79+
const [resolve2, state2] = counting();
80+
await resolveTspEndpointCachedWith("did:web:peer-99.example", resolve2);
81+
assert.equal(state2.calls, 0, "the newest entry must survive");
82+
await resolveTspEndpointCachedWith("did:web:peer-0.example", resolve2);
83+
assert.equal(state2.calls, 1, "the oldest entry must have been evicted");
84+
});

packages/extension/src/offscreen.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
resolveVtaServices,
4040
type VtaServices,
4141
resolveVtaTspEndpoint,
42+
resolveVtaTspEndpointCached,
4243
unpackInboundTsp,
4344
RestChannel,
4445
TspChannel,
@@ -2071,7 +2072,13 @@ async function onInboundTspFrame(
20712072
// operator-enrolled control plane. Whether the sender is one we accept
20722073
// is decided downstream, on the document's proof — not here, and not on
20732074
// the strength of the transport.
2074-
resolveSender: resolveVtaTspEndpoint,
2075+
//
2076+
// The **cached** form, because this runs serially per frame on the socket
2077+
// that also carries replies: an uncached resolution here is up to two
2078+
// network round-trips per inbound frame, and a redelivery burst starves
2079+
// an in-flight request into a hard TSP timeout. See
2080+
// `resolveVtaTspEndpointCached`.
2081+
resolveSender: resolveVtaTspEndpointCached,
20752082
});
20762083
} catch (err) {
20772084
// Logged, not swallowed: a frame that repeatedly fails to verify is a

0 commit comments

Comments
 (0)