Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 69 additions & 7 deletions packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ import {
import { runSyncOnConnect, SyncOnConnectPostSyncError, type SyncOnConnectOutcome, type SyncOnConnectPeerOutcome } from './sync/on-connect/sync-on-connect.js';
import { mapWithConcurrency } from './map-with-concurrency.js';
import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js';
import { retryCatchupPlaneOnBackpressure } from './sync/catchup-backpressure-retry.js';
import { classifyDurableProgress } from './sync/durable-progress.js';
import {
getSyncBackpressureSnapshot,
Expand Down Expand Up @@ -607,12 +608,16 @@ function contextGraphCatchupSingleFlightKey(params: {
includeSharedMemory: boolean;
maxPeers?: number;
peerRotationKey?: string;
priority?: number;
retryDeferredBackpressure?: boolean;
}): string {
return syncSingleFlightKey('context-graph-catchup', {
contextGraphId: params.contextGraphId,
includeSharedMemory: params.includeSharedMemory,
maxPeers: normalizedCatchupMaxPeers(params.maxPeers),
peerRotationKey: params.peerRotationKey ?? null,
priority: params.priority ?? null,
retryDeferredBackpressure: params.retryDeferredBackpressure === true,
});
}

Expand All @@ -626,6 +631,7 @@ function durableSyncSingleFlightKey(params: {
hasAccessDeniedCallback: boolean;
hasSinceBatchIdResolver: boolean;
exactAssetUals?: readonly string[];
priority?: number;
}): string | null {
if (params.hasPhaseCallback || params.hasAccessDeniedCallback || params.hasSinceBatchIdResolver) {
return null;
Expand All @@ -637,6 +643,7 @@ function durableSyncSingleFlightKey(params: {
totalTimeoutMs: params.totalTimeoutMs,
syncAgentsMeta: params.syncAgentsMeta,
exactAssetUals: params.exactAssetUals ?? null,
priority: params.priority ?? null,
});
}

Expand All @@ -646,13 +653,15 @@ function sharedMemorySyncSingleFlightKey(params: {
stopOnBackoffWorthyFailure?: boolean;
publicContextGraphIds: readonly string[];
privateRecoverFromCurator: readonly string[];
priority?: number;
}): string {
return syncSingleFlightKey('shared-memory-sync', {
remotePeerId: params.remotePeerId,
contextGraphIds: params.contextGraphIds,
stopOnBackoffWorthyFailure: params.stopOnBackoffWorthyFailure === true,
publicContextGraphIds: params.publicContextGraphIds,
privateRecoverFromCurator: params.privateRecoverFromCurator,
priority: params.priority ?? null,
});
}

Expand Down Expand Up @@ -4042,7 +4051,13 @@ export class LifecycleSyncMethods extends DKGAgentBase {
// the same flag that makes it a responder. Same signal SC4 uses to advertise the protocol.
if (asChangelogReader(this.store) !== null && contextGraphIds.length > 0) {
try {
const lane = await this.runChangelogLane(ctx, remotePeerId, contextGraphIds, onAccessDenied);
const lane = await this.runChangelogLane(
ctx,
remotePeerId,
contextGraphIds,
onAccessDenied,
options?.priority,
);
changelogResult = lane.result;
legacyContextGraphIds = lane.remainingLegacyCgs;
if ((changelogResult.deferredBackpressure ?? 0) > 0) return changelogResult;
Expand Down Expand Up @@ -4142,6 +4157,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
hasAccessDeniedCallback: Boolean(onAccessDenied),
hasSinceBatchIdResolver: Boolean(sinceBatchIdFor),
exactAssetUals: options?.exactAssetUals,
priority: options?.priority,
});
return singleFlightKey ? runSyncSingleFlight(this, singleFlightKey, runSync) : runSync();
}
Expand Down Expand Up @@ -4293,6 +4309,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
remotePeerId: string,
contextGraphIds: string[],
onAccessDenied?: (contextGraphId: string) => void,
priority?: number,
): Promise<{ result: DurableSyncResult; remainingLegacyCgs: string[] }> {
const peerProtocols = await this.getPeerProtocols(remotePeerId);
if (!peerProtocols.includes(PROTOCOL_SYNC_CHANGELOG)) {
Expand Down Expand Up @@ -4333,6 +4350,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
item.lane,
item.operationId,
run,
priority,
),
merge: mergeDurableSyncResults,
markDeferred: (summary) => ({
Expand Down Expand Up @@ -4761,6 +4779,8 @@ export class LifecycleSyncMethods extends DKGAgentBase {
options?: {
stopOnBackoffWorthyFailure?: boolean;
sharedMemorySyncPlan?: SharedMemorySyncContextGraphPlan;
/** Admission override for foreground catch-up. */
priority?: number;
},
): Promise<SharedMemorySyncResult> {
const ctx = createOperationContext('sync');
Expand Down Expand Up @@ -4827,6 +4847,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
stopOnBackoffWorthyFailure,
publicContextGraphIds,
privateRecoverFromCurator,
priority: options?.priority,
});

const runSync = async (): Promise<SharedMemorySyncResult> => {
Expand Down Expand Up @@ -4980,6 +5001,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
item.lane,
item.operationId,
run,
options?.priority,
),
merge: mergeSharedMemorySyncResults,
markDeferred: (summary) => ({
Expand Down Expand Up @@ -5084,7 +5106,15 @@ export class LifecycleSyncMethods extends DKGAgentBase {
*/
async syncContextGraphFromConnectedPeers(this: DKGAgent,
contextGraphId: string,
options?: { includeSharedMemory?: boolean; maxPeers?: number; peerRotationKey?: string },
options?: {
includeSharedMemory?: boolean;
maxPeers?: number;
peerRotationKey?: string;
/** Admission override used by explicit foreground catch-up callers. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Model foreground catch-up as one admission policy, not loose flags

What's wrong
The foreground catch-up concept leaks through several layers as an optional number plus an optional boolean. Those options only make sense as a coordinated policy, but the API lets callers assemble mismatched combinations and forces readers to trace multiple flags through durable, changelog, shared-memory, and CLI worker boundaries.

Example
A new explicit catch-up caller can pass { priority: FOREGROUND_CATCHUP_SYNC_PRIORITY } without retryDeferredBackpressure, or retry deferred work without foreground priority. Both combinations are representable even though the comments describe one foreground admission mode.

Suggested direction
Replace the naked priority plus retryDeferredBackpressure plumbing with a named mode or typed SyncAdmissionPolicy that carries the intended priority, retry behavior, and single-flight identity together.

For Agents
Review DurableSyncOptions, syncSharedMemoryFromPeerDetailed options, syncContextGraphFromConnectedPeers options, and the CLI runner calls. Preserve the current foreground priority value and bounded retry behavior, but represent them as one named admission policy or mode and derive the single-flight key fields from that policy.

priority?: number;
/** Retry only locally-deferred planes; completed planes are not rerun. */
retryDeferredBackpressure?: boolean;
},
): Promise<{
/** Ordered connected peers before optional maxPeers windowing. */
connectedPeers: number;
Expand Down Expand Up @@ -5137,6 +5167,8 @@ export class LifecycleSyncMethods extends DKGAgentBase {
includeSharedMemory,
maxPeers: options?.maxPeers,
peerRotationKey: options?.peerRotationKey,
priority: options?.priority,
retryDeferredBackpressure: options?.retryDeferredBackpressure,
});

return runSyncSingleFlight(this, singleFlightKey, async (): Promise<ContextGraphCatchupResult> => {
Expand Down Expand Up @@ -5186,6 +5218,8 @@ export class LifecycleSyncMethods extends DKGAgentBase {
);
return this.runCatchupOverPeers(contextGraphId, includeSharedMemory, peers, {
totalPeers: orderedPeers.length,
priority: options?.priority,
retryDeferredBackpressure: options?.retryDeferredBackpressure,
});
});
}
Expand Down Expand Up @@ -5276,7 +5310,11 @@ export class LifecycleSyncMethods extends DKGAgentBase {
contextGraphId: string,
includeSharedMemory: boolean,
peers: Array<{ toString(): string }>,
stats?: { totalPeers?: number },
stats?: {
totalPeers?: number;
priority?: number;
retryDeferredBackpressure?: boolean;
},
): Promise<{
/** Ordered connected peers before optional caller windowing. */
connectedPeers: number;
Expand Down Expand Up @@ -5432,13 +5470,37 @@ export class LifecycleSyncMethods extends DKGAgentBase {
syncCapable,
CATCHUP_MAX_CONCURRENT_PEER_SYNCS,
async (remotePeerId) => {
const durable = await this.syncFromPeerDetailed(
const runDurable = () => this.syncFromPeerDetailed(
remotePeerId,
[contextGraphId],
undefined,
undefined,
undefined,
stats?.priority === undefined ? undefined : { priority: stats.priority },
);
const durable = await (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Centralize the foreground catch-up plane sequencing instead of duplicating it

What's wrong
The retry helper only abstracts the delay loop, leaving the higher-value policy scattered in two runners. That makes the architecture more fragile and keeps the inline and worker catch-up paths coupled by convention rather than code.

Example
A future change to the foreground catch-up policy, such as changing when SWM can start or how a deferred durable result is handled, now has to be made in both the inline lifecycle path and the worker path. The code already has comments elsewhere saying the two paths must mirror each other; this diff adds another mirrored behavior instead of making that invariant structural.

Suggested direction
Move the durable/SWM sequencing policy into one shared helper in the agent sync layer. The worker can still supply RPC-backed closures, but it should not have to know or duplicate the durable-before-SWM retry policy.

For Agents
Look at LifecycleSyncMethods.runCatchupOverPeers and runCatchup in catchup-runner-worker-impl.ts. Preserve current behavior: retry durable on local backpressure, do not start SWM while durable remains deferred, retry SWM without rerunning completed durable work, and keep per-peer failure isolation. Extract a shared runCatchupPeerSyncRound-style helper that takes durable/shared closures and empty-result handlers, then use it from both paths.

stats?.retryDeferredBackpressure

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Agent catch-up retry behavior is not verified at the agent entry point

What's wrong
The changed branch is what inline/direct foreground catch-up uses, but the new tests do not call this entry point with retryDeferredBackpressure. A regression that removed this retry wrapper, ran SWM while durable was still locally deferred, or reran durable after only SWM deferred would still pass the added helper and worker-implementation tests.

Example
Failing-test sketch: stub one connected sync-capable peer, make syncFromPeerDetailed return { deferredBackpressure: 1 } and then a clean durable result, call syncContextGraphFromConnectedPeers('cg', { includeSharedMemory: true, retryDeferredBackpressure: true, priority: 2000 }), and assert durable is called twice, SWM is not called until durable clears, and the final result has deferredBackpressure: 0.

Suggested direction
Add a targeted agent-path test for the new retry flag instead of relying only on the helper unit test and the worker implementation's duplicate flow.

For Agents
Add an agent-level regression test near packages/agent/test/sync-fetch-coalescing.test.ts or the catch-up tests. Exercise syncContextGraphFromConnectedPeers or runCatchupOverPeers with retryDeferredBackpressure: true, preserve the no-rerun durable behavior when only SWM defers, and prove the aggregate result clears or reports deferred pressure correctly.

? retryCatchupPlaneOnBackpressure(runDurable)
: runDurable()
).catch(emptyDurable);
const shared = includeSharedMemory
? await this.syncSharedMemoryFromPeerDetailed(remotePeerId, [contextGraphId]).catch(emptyShared)
: null;

// SWM authorization/materialization depends on durable metadata. If
// durable admission remains deferred, do not manufacture a premature
// SWM denial. Once durable completes, retry only SWM; a successful VM
// plane is never fetched again just because SWM hit local pressure.
let shared: SharedMemorySyncResult | null = null;
if (includeSharedMemory && (durable.deferredBackpressure ?? 0) === 0) {
const runShared = () => this.syncSharedMemoryFromPeerDetailed(
remotePeerId,
[contextGraphId],
stats?.priority === undefined ? undefined : { priority: stats.priority },
);
shared = await (
stats?.retryDeferredBackpressure
? retryCatchupPlaneOnBackpressure(runShared)
: runShared()
).catch(emptyShared);
}
return { durable, shared };
},
);
Expand Down
6 changes: 6 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ export {
// deep-importing the compiled `dist/` module.
export { mapWithConcurrency } from './map-with-concurrency.js';
export { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js';
export {
CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS,
FOREGROUND_CATCHUP_SYNC_PRIORITY,
retryCatchupPlaneOnBackpressure,
type CatchupBackpressureResult,
} from './sync/catchup-backpressure-retry.js';
export {
classifyDurableProgress,
type DurableProgressClassification,
Expand Down
39 changes: 39 additions & 0 deletions packages/agent/src/sync/catchup-backpressure-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* User-requested catch-up must outrank autonomous exact-VM repair (priority
* 1_000) and ordinary background sync (priority 0). This lets a subscribe or
* explicit catch-up displace queued background work instead of being marked
* deferred before it has fetched a byte.
*/
export const FOREGROUND_CATCHUP_SYNC_PRIORITY = 2_000;

/**
* Admission can still race another foreground catch-up. Retry that local-only
* outcome briefly; transport, authorization, timeout, and integrity failures
* are deliberately not retried here.
*/
export const CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS = [100, 250, 500] as const;

export interface CatchupBackpressureResult {
deferredBackpressure?: number;
}

export async function retryCatchupPlaneOnBackpressure<T extends CatchupBackpressureResult>(
run: () => Promise<T>,
options?: {
delaysMs?: readonly number[];
wait?: (delayMs: number) => Promise<void>;
},
): Promise<T> {
const delaysMs = options?.delaysMs ?? CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS;
const wait = options?.wait ?? ((delayMs: number) => new Promise<void>((resolve) => {
setTimeout(resolve, delayMs);
}));

let result = await run();
for (const delayMs of delaysMs) {
if ((result.deferredBackpressure ?? 0) === 0) break;
await wait(delayMs);
result = await run();
}
return result;
}
44 changes: 44 additions & 0 deletions packages/agent/test/catchup-backpressure-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import {
CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS,
retryCatchupPlaneOnBackpressure,
} from '../src/sync/catchup-backpressure-retry.js';

describe('retryCatchupPlaneOnBackpressure', () => {
it('retries only the local scheduler deferral result', async () => {
const run = vi.fn()
.mockResolvedValueOnce({ deferredBackpressure: 1, marker: 'deferred' })
.mockResolvedValueOnce({ deferredBackpressure: 0, marker: 'complete' });
const waits: number[] = [];

const result = await retryCatchupPlaneOnBackpressure(run, {
delaysMs: [3, 5],
wait: async (delayMs) => { waits.push(delayMs); },
});

expect(result).toEqual({ deferredBackpressure: 0, marker: 'complete' });
expect(run).toHaveBeenCalledTimes(2);
expect(waits).toEqual([3]);
});

it('returns the final deferred result after the bounded retry budget', async () => {
const run = vi.fn(async () => ({ deferredBackpressure: 1 }));

const result = await retryCatchupPlaneOnBackpressure(run, {
wait: async () => {},
});

expect(result.deferredBackpressure).toBe(1);
expect(run).toHaveBeenCalledTimes(CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS.length + 1);
});

it('does not retry a clean result', async () => {
const run = vi.fn(async () => ({ deferredBackpressure: 0 }));

await retryCatchupPlaneOnBackpressure(run, {
wait: async () => { throw new Error('must not wait'); },
});

expect(run).toHaveBeenCalledTimes(1);
});
});
Loading
Loading