Skip to content

Commit c782fa9

Browse files
committed
refactor(delivery): share recovery primitives
1 parent 81e1ec4 commit c782fa9

3 files changed

Lines changed: 55 additions & 71 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const RECOVERY_BACKOFF_MS: readonly number[] = [5_000, 25_000, 120_000, 600_000];
2+
3+
export function computeBackoffMs(retryCount: number): number {
4+
if (retryCount <= 0) {
5+
return 0;
6+
}
7+
return (
8+
RECOVERY_BACKOFF_MS[Math.min(retryCount - 1, RECOVERY_BACKOFF_MS.length - 1)] ??
9+
RECOVERY_BACKOFF_MS.at(-1) ??
10+
0
11+
);
12+
}
13+
14+
export function getErrnoCode(err: unknown): string | null {
15+
return err && typeof err === "object" && "code" in err
16+
? String((err as { code?: unknown }).code)
17+
: null;
18+
}
19+
20+
export function claimRecoveryEntry(entriesInProgress: Set<string>, entryId: string): boolean {
21+
if (entriesInProgress.has(entryId)) {
22+
return false;
23+
}
24+
entriesInProgress.add(entryId);
25+
return true;
26+
}
27+
28+
export function releaseRecoveryEntry(entriesInProgress: Set<string>, entryId: string): void {
29+
entriesInProgress.delete(entryId);
30+
}

src/infra/outbound/delivery-queue-recovery.ts

Lines changed: 14 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import type {
99
ChannelMessageUnknownSendReconciliationResult,
1010
} from "../../channels/message/types.js";
1111
import type { OpenClawConfig } from "../../config/types.openclaw.js";
12+
import {
13+
claimRecoveryEntry as claimSharedRecoveryEntry,
14+
computeBackoffMs,
15+
getErrnoCode,
16+
releaseRecoveryEntry as releaseSharedRecoveryEntry,
17+
} from "../delivery-recovery.shared.js";
1218
import { formatErrorMessage } from "../errors.js";
1319
import { resolveOutboundChannelMessageAdapter } from "./channel-resolution.js";
1420
import type { OutboundDeliveryResult } from "./deliver-types.js";
@@ -26,6 +32,8 @@ import {
2632
type QueuedDeliveryPayload,
2733
} from "./delivery-queue-storage.js";
2834

35+
export { computeBackoffMs };
36+
2937
export type RecoverySummary = {
3038
recovered: number;
3139
failed: number;
@@ -61,14 +69,6 @@ export type ActiveDeliveryClaimResult<T> =
6169

6270
const MAX_RETRIES = 5;
6371

64-
/** Backoff delays in milliseconds indexed by retry count (1-based). */
65-
const BACKOFF_MS: readonly number[] = [
66-
5_000, // retry 1: 5s
67-
25_000, // retry 2: 25s
68-
120_000, // retry 3: 2m
69-
600_000, // retry 4: 10m
70-
];
71-
7272
const PERMANENT_ERROR_PATTERNS: readonly RegExp[] = [
7373
/no conversation reference found/i,
7474
/chat not found/i,
@@ -97,12 +97,6 @@ function resolveRecoveryDeadlineMs(maxRecoveryMs: number | undefined): number {
9797
return resolveExpiresAtMsFromDurationMs(durationMs) ?? resolveDateTimestampMs(Date.now());
9898
}
9999

100-
function getErrnoCode(err: unknown): string | null {
101-
return err && typeof err === "object" && "code" in err
102-
? String((err as { code?: unknown }).code)
103-
: null;
104-
}
105-
106100
function createEmptyRecoverySummary(): RecoverySummary {
107101
return {
108102
recovered: 0,
@@ -112,30 +106,18 @@ function createEmptyRecoverySummary(): RecoverySummary {
112106
};
113107
}
114108

115-
function claimRecoveryEntry(entryId: string): boolean {
116-
if (entriesInProgress.has(entryId)) {
117-
return false;
118-
}
119-
entriesInProgress.add(entryId);
120-
return true;
121-
}
122-
123-
function releaseRecoveryEntry(entryId: string): void {
124-
entriesInProgress.delete(entryId);
125-
}
126-
127109
export async function withActiveDeliveryClaim<T>(
128110
entryId: string,
129111
fn: () => Promise<T>,
130112
): Promise<ActiveDeliveryClaimResult<T>> {
131-
if (!claimRecoveryEntry(entryId)) {
113+
if (!claimSharedRecoveryEntry(entriesInProgress, entryId)) {
132114
return { status: "claimed-by-other-owner" };
133115
}
134116

135117
try {
136118
return { status: "claimed", value: await fn() };
137119
} finally {
138-
releaseRecoveryEntry(entryId);
120+
releaseSharedRecoveryEntry(entriesInProgress, entryId);
139121
}
140122
}
141123

@@ -326,14 +308,6 @@ async function moveEntryToFailedWithLogging(
326308
}
327309
}
328310

329-
/** Compute the backoff delay in ms for a given retry count. */
330-
export function computeBackoffMs(retryCount: number): number {
331-
if (retryCount <= 0) {
332-
return 0;
333-
}
334-
return BACKOFF_MS[Math.min(retryCount - 1, BACKOFF_MS.length - 1)] ?? BACKOFF_MS.at(-1) ?? 0;
335-
}
336-
337311
export function isEntryEligibleForRecoveryRetry(
338312
entry: QueuedDelivery,
339313
now: number,
@@ -513,7 +487,7 @@ export async function drainPendingDeliveries(opts: {
513487
);
514488

515489
for (const entry of matchingEntries) {
516-
if (!claimRecoveryEntry(entry.id)) {
490+
if (!claimSharedRecoveryEntry(entriesInProgress, entry.id)) {
517491
opts.log.info(`${opts.logLabel}: entry ${entry.id} is already being recovered`);
518492
continue;
519493
}
@@ -582,7 +556,7 @@ export async function drainPendingDeliveries(opts: {
582556
);
583557
}
584558
} finally {
585-
releaseRecoveryEntry(entry.id);
559+
releaseSharedRecoveryEntry(entriesInProgress, entry.id);
586560
}
587561
}
588562
} finally {
@@ -622,7 +596,7 @@ export async function recoverPendingDeliveries(opts: {
622596
break;
623597
}
624598

625-
if (!claimRecoveryEntry(entry.id)) {
599+
if (!claimSharedRecoveryEntry(entriesInProgress, entry.id)) {
626600
opts.log.info(`Recovery skipped for delivery ${entry.id}: already being processed`);
627601
continue;
628602
}
@@ -677,7 +651,7 @@ export async function recoverPendingDeliveries(opts: {
677651
continue;
678652
}
679653
} finally {
680-
releaseRecoveryEntry(entry.id);
654+
releaseSharedRecoveryEntry(entriesInProgress, entry.id);
681655
}
682656
}
683657

src/infra/session-delivery-queue-recovery.ts

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import {
44
resolveExpiresAtMsFromDurationMs,
55
resolveNonNegativeIntegerOption,
66
} from "@openclaw/normalization-core/number-coercion";
7+
import {
8+
claimRecoveryEntry as claimSharedRecoveryEntry,
9+
computeBackoffMs,
10+
getErrnoCode,
11+
releaseRecoveryEntry as releaseSharedRecoveryEntry,
12+
} from "./delivery-recovery.shared.js";
713
import { formatErrorMessage } from "./errors.js";
814
import {
915
ackSessionDelivery,
@@ -38,16 +44,9 @@ interface PendingSessionDeliveryDrainDecision {
3844

3945
const MAX_SESSION_DELIVERY_RETRIES = 5;
4046

41-
const BACKOFF_MS: readonly number[] = [5_000, 25_000, 120_000, 600_000];
4247
const drainInProgress = new Map<string, boolean>();
4348
const entriesInProgress = new Set<string>();
4449

45-
function getErrnoCode(err: unknown): string | null {
46-
return err && typeof err === "object" && "code" in err
47-
? String((err as { code?: unknown }).code)
48-
: null;
49-
}
50-
5150
function createEmptyRecoverySummary(): SessionDeliveryRecoverySummary {
5251
return {
5352
recovered: 0,
@@ -57,25 +56,6 @@ function createEmptyRecoverySummary(): SessionDeliveryRecoverySummary {
5756
};
5857
}
5958

60-
function claimRecoveryEntry(entryId: string): boolean {
61-
if (entriesInProgress.has(entryId)) {
62-
return false;
63-
}
64-
entriesInProgress.add(entryId);
65-
return true;
66-
}
67-
68-
function releaseRecoveryEntry(entryId: string): void {
69-
entriesInProgress.delete(entryId);
70-
}
71-
72-
function computeSessionDeliveryBackoffMs(retryCount: number): number {
73-
if (retryCount <= 0) {
74-
return 0;
75-
}
76-
return BACKOFF_MS[Math.min(retryCount - 1, BACKOFF_MS.length - 1)] ?? BACKOFF_MS.at(-1) ?? 0;
77-
}
78-
7959
function resolveSessionDeliveryMaxRetries(entry: QueuedSessionDelivery): number {
8060
return entry.maxRetries ?? MAX_SESSION_DELIVERY_RETRIES;
8161
}
@@ -92,7 +72,7 @@ export function isSessionDeliveryEligibleForRetry(
9272
entry: QueuedSessionDelivery,
9373
now: number,
9474
): { eligible: true } | { eligible: false; remainingBackoffMs: number } {
95-
const backoff = computeSessionDeliveryBackoffMs(entry.retryCount);
75+
const backoff = computeBackoffMs(entry.retryCount);
9676
if (backoff <= 0) {
9777
return { eligible: true };
9878
}
@@ -160,7 +140,7 @@ export async function drainPendingSessionDeliveries(opts: {
160140
.toSorted((a, b) => a.enqueuedAt - b.enqueuedAt);
161141

162142
for (const entry of matchingEntries) {
163-
if (!claimRecoveryEntry(entry.id)) {
143+
if (!claimSharedRecoveryEntry(entriesInProgress, entry.id)) {
164144
opts.log.info(`${opts.logLabel}: entry ${entry.id} is already being recovered`);
165145
continue;
166146
}
@@ -207,7 +187,7 @@ export async function drainPendingSessionDeliveries(opts: {
207187
},
208188
});
209189
} finally {
210-
releaseRecoveryEntry(entry.id);
190+
releaseSharedRecoveryEntry(entriesInProgress, entry.id);
211191
}
212192
}
213193
} finally {
@@ -239,7 +219,7 @@ export async function recoverPendingSessionDeliveries(opts: {
239219
opts.log.warn("Session delivery recovery time budget exceeded — remaining entries deferred");
240220
break;
241221
}
242-
if (!claimRecoveryEntry(entry.id)) {
222+
if (!claimSharedRecoveryEntry(entriesInProgress, entry.id)) {
243223
continue;
244224
}
245225

@@ -285,7 +265,7 @@ export async function recoverPendingSessionDeliveries(opts: {
285265
opts.log.info(`Recovered session delivery ${currentEntry.id}`);
286266
}
287267
} finally {
288-
releaseRecoveryEntry(entry.id);
268+
releaseSharedRecoveryEntry(entriesInProgress, entry.id);
289269
}
290270
}
291271

0 commit comments

Comments
 (0)