-
Notifications
You must be signed in to change notification settings - Fork 10
Promote #1895 to main (catchup backpressure retry) — release-bound for v10.0.9 #1896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -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; | ||
|
|
@@ -637,6 +643,7 @@ function durableSyncSingleFlightKey(params: { | |
| totalTimeoutMs: params.totalTimeoutMs, | ||
| syncAgentsMeta: params.syncAgentsMeta, | ||
| exactAssetUals: params.exactAssetUals ?? null, | ||
| priority: params.priority ?? null, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -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, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -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; | ||
|
|
@@ -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(); | ||
| } | ||
|
|
@@ -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)) { | ||
|
|
@@ -4333,6 +4350,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { | |
| item.lane, | ||
| item.operationId, | ||
| run, | ||
| priority, | ||
| ), | ||
| merge: mergeDurableSyncResults, | ||
| markDeferred: (summary) => ({ | ||
|
|
@@ -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'); | ||
|
|
@@ -4827,6 +4847,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { | |
| stopOnBackoffWorthyFailure, | ||
| publicContextGraphIds, | ||
| privateRecoverFromCurator, | ||
| priority: options?.priority, | ||
| }); | ||
|
|
||
| const runSync = async (): Promise<SharedMemorySyncResult> => { | ||
|
|
@@ -4980,6 +5001,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { | |
| item.lane, | ||
| item.operationId, | ||
| run, | ||
| options?.priority, | ||
| ), | ||
| merge: mergeSharedMemorySyncResults, | ||
| markDeferred: (summary) => ({ | ||
|
|
@@ -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. */ | ||
| priority?: number; | ||
| /** Retry only locally-deferred planes; completed planes are not rerun. */ | ||
| retryDeferredBackpressure?: boolean; | ||
| }, | ||
| ): Promise<{ | ||
| /** Ordered connected peers before optional maxPeers windowing. */ | ||
| connectedPeers: number; | ||
|
|
@@ -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> => { | ||
|
|
@@ -5186,6 +5218,8 @@ export class LifecycleSyncMethods extends DKGAgentBase { | |
| ); | ||
| return this.runCatchupOverPeers(contextGraphId, includeSharedMemory, peers, { | ||
| totalPeers: orderedPeers.length, | ||
| priority: options?.priority, | ||
| retryDeferredBackpressure: options?.retryDeferredBackpressure, | ||
| }); | ||
| }); | ||
| } | ||
|
|
@@ -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; | ||
|
|
@@ -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 ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Example Suggested direction For Agents |
||
| stats?.retryDeferredBackpressure | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Example Suggested direction For Agents |
||
| ? 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 }; | ||
| }, | ||
| ); | ||
|
|
||
| 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; | ||
| } |
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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 }withoutretryDeferredBackpressure, 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
priorityplusretryDeferredBackpressureplumbing with a named mode or typedSyncAdmissionPolicythat carries the intended priority, retry behavior, and single-flight identity together.For Agents
Review
DurableSyncOptions,syncSharedMemoryFromPeerDetailedoptions,syncContextGraphFromConnectedPeersoptions, 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.