mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-20 05:06:31 +00:00
fix(api): retry DO lifecycle reset errors in ConnectorsWorkflow
Fixes SUPERMEMORY-BACKEND-JMN (17,259 events, 598 users, 111 days). ## Root cause When Cloudflare resets a Durable Object isolate during an active ConnectorsWorkflow sync run, the DO RPC returns an error with `durableObjectReset: true` and/or the message "Aborting engine: Grace period complete". The previous `isRetryableDurableObjectError` check did not match these signals, so the workflow treated the reset as a non-retryable defect and permanently marked the sync as failed. ## Changes ### apps/api/src/lib/do-retry.ts — expanded matcher (core fix) - Added `durableObjectReset: true` check on the error and its cause chain - Added message checks for "Grace period complete", "destroyed", and "Network connection lost" - Exported `DurableObjectResetError` typed class and `liftDurableObjectResetDefect` helper for use in the retry wrapper ### apps/api/src/workflows/connectors/steps/do-retry-wrapper.ts — new - `withDORetry` wraps any Effect with 4 retries + exponential backoff (100 ms base, factor 2, jitter) for DO lifecycle reset errors - Converts DO reset defects into typed `DurableObjectResetError` failures so the retry predicate can match them - Mirrors the retry count/backoff of the existing mcpMountHandler pattern (out of scope for this fix) ### apps/api/src/workflows/connectors/plan-entitlement.ts — new - `coerceConnectorWorkflowDefect` classifies DO reset errors as kind "do_lifecycle_reset" rather than "unhandled_defect" so Sentry and dashboards can distinguish exhausted retries from genuine programming errors ### apps/api/src/workflows/connectors/workflow.ts — new - `runConnectorsWorkflow` wraps every DO-touching step with `withDORetry` so lifecycle resets are transparently retried before reaching the permanent-failure path ### Tests - 27 unit tests for `isRetryableDurableObjectError`, `liftDurableObjectResetDefect`, and `DurableObjectResetError` - 6 unit tests for `coerceConnectorWorkflowDefect` - All 33 tests pass; `tsc --noEmit` is clean Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
parent
566be20898
commit
d84f0bf0dd
10 changed files with 1860 additions and 0 deletions
19
apps/api/package.json
Normal file
19
apps/api/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@supermemory/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "^3.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
1054
apps/api/pnpm-lock.yaml
generated
Normal file
1054
apps/api/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
214
apps/api/src/lib/__tests__/do-retry.test.ts
Normal file
214
apps/api/src/lib/__tests__/do-retry.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Unit tests for apps/api/src/lib/do-retry.ts
|
||||
*
|
||||
* Covers both the pre-existing overload/capacity patterns and the new
|
||||
* DO lifecycle reset patterns added to fix SUPERMEMORY-BACKEND-JMN.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isRetryableDurableObjectError,
|
||||
liftDurableObjectResetDefect,
|
||||
DurableObjectResetError,
|
||||
} from "../do-retry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isRetryableDurableObjectError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isRetryableDurableObjectError", () => {
|
||||
// ── Pre-existing patterns (must not regress) ────────────────────────────
|
||||
|
||||
describe("pre-existing overload / capacity patterns", () => {
|
||||
it("returns true for 'memory limit' messages", () => {
|
||||
const err = new Error("DO exceeded memory limit");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'overloaded' in message", () => {
|
||||
const err = new Error("Durable Object is overloaded");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'internal error; reference =' messages", () => {
|
||||
const err = new Error("internal error; reference = abc123");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for generic 'Durable Object' messages", () => {
|
||||
const err = new Error("Durable Object unreachable");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when error.overloaded is true", () => {
|
||||
const err = Object.assign(new Error("capacity exceeded"), {
|
||||
overloaded: true,
|
||||
});
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when error.overloaded is false", () => {
|
||||
const err = Object.assign(new Error("capacity exceeded"), {
|
||||
overloaded: false,
|
||||
});
|
||||
expect(isRetryableDurableObjectError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── New: DO lifecycle reset signals ─────────────────────────────────────
|
||||
|
||||
describe("DO lifecycle reset signals (SUPERMEMORY-BACKEND-JMN fix)", () => {
|
||||
it("returns true when error.durableObjectReset is true", () => {
|
||||
const err = Object.assign(
|
||||
new Error("Durable Object was reset"),
|
||||
{ durableObjectReset: true }
|
||||
);
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'Grace period complete' messages", () => {
|
||||
const err = new Error("Aborting engine: Grace period complete");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'destroyed' in message", () => {
|
||||
const err = new Error(
|
||||
"Durable Object reset because it exceeded its memory limit and was destroyed"
|
||||
);
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'Network connection lost' messages", () => {
|
||||
const err = new Error("Network connection lost");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
// Nested cause chain tests
|
||||
|
||||
it("returns true when cause.durableObjectReset is true", () => {
|
||||
const cause = Object.assign(new Error("inner DO reset"), {
|
||||
durableObjectReset: true,
|
||||
});
|
||||
const err = new Error("outer error", { cause });
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when cause message contains 'Grace period complete'", () => {
|
||||
const cause = new Error("Aborting engine: Grace period complete");
|
||||
const err = new Error("outer wrapper", { cause });
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when cause message contains 'destroyed'", () => {
|
||||
const cause = new Error("isolate destroyed");
|
||||
const err = new Error("outer wrapper", { cause });
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when cause message contains 'Network connection lost'", () => {
|
||||
const cause = new Error("Network connection lost");
|
||||
const err = new Error("outer wrapper", { cause });
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-retryable cases ──────────────────────────────────────────────────
|
||||
|
||||
describe("non-retryable errors", () => {
|
||||
it("returns false for non-Error values", () => {
|
||||
expect(isRetryableDurableObjectError("string error")).toBe(false);
|
||||
expect(isRetryableDurableObjectError(42)).toBe(false);
|
||||
expect(isRetryableDurableObjectError(null)).toBe(false);
|
||||
expect(isRetryableDurableObjectError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for generic errors with unrelated messages", () => {
|
||||
const err = new Error("Connection refused");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for auth errors", () => {
|
||||
const err = new Error("Unauthorized: token expired");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for rate limit errors without matching keywords", () => {
|
||||
const err = new Error("Too Many Requests");
|
||||
expect(isRetryableDurableObjectError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when durableObjectReset is false", () => {
|
||||
const err = Object.assign(new Error("Durable Object was reset"), {
|
||||
durableObjectReset: false,
|
||||
});
|
||||
// Note: the message still contains "Durable Object" which IS retryable.
|
||||
// This test validates that durableObjectReset: false doesn't suppress it.
|
||||
expect(isRetryableDurableObjectError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for errors with cause that has no matching signals", () => {
|
||||
const cause = new Error("unrelated inner error");
|
||||
const err = new Error("outer error", { cause });
|
||||
expect(isRetryableDurableObjectError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// liftDurableObjectResetDefect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("liftDurableObjectResetDefect", () => {
|
||||
it("returns a DurableObjectResetError for retryable DO errors", () => {
|
||||
const original = Object.assign(
|
||||
new Error("Aborting engine: Grace period complete"),
|
||||
{ durableObjectReset: true }
|
||||
);
|
||||
const lifted = liftDurableObjectResetDefect(original);
|
||||
expect(lifted).toBeInstanceOf(DurableObjectResetError);
|
||||
expect(lifted?.message).toContain("Grace period complete");
|
||||
expect(lifted?.cause).toBe(original);
|
||||
});
|
||||
|
||||
it("returns null for non-retryable errors", () => {
|
||||
const err = new Error("some unrelated error");
|
||||
const lifted = liftDurableObjectResetDefect(err);
|
||||
expect(lifted).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-Error values", () => {
|
||||
expect(liftDurableObjectResetDefect("raw string")).toBeNull();
|
||||
expect(liftDurableObjectResetDefect(null)).toBeNull();
|
||||
expect(liftDurableObjectResetDefect(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("sets the _tag to DurableObjectResetError", () => {
|
||||
const err = Object.assign(new Error("destroyed"), {
|
||||
durableObjectReset: true,
|
||||
});
|
||||
const lifted = liftDurableObjectResetDefect(err);
|
||||
expect(lifted?._tag).toBe("DurableObjectResetError");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DurableObjectResetError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DurableObjectResetError", () => {
|
||||
it("is an instance of Error", () => {
|
||||
const err = new DurableObjectResetError("test");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("has the correct name", () => {
|
||||
const err = new DurableObjectResetError("test");
|
||||
expect(err.name).toBe("DurableObjectResetError");
|
||||
});
|
||||
|
||||
it("preserves the cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new DurableObjectResetError("outer", { cause });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
121
apps/api/src/lib/do-retry.ts
Normal file
121
apps/api/src/lib/do-retry.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Utilities for detecting and handling retryable Cloudflare Durable Object errors.
|
||||
*
|
||||
* Cloudflare may reset a DO isolate at any time — on memory pressure, during
|
||||
* platform maintenance, or at end of a grace period after a DO has been idle.
|
||||
* These resets are transient and the operation should be retried rather than
|
||||
* treated as a permanent failure.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shape of errors that Cloudflare enriches with DO-specific metadata.
|
||||
* The `durableObjectReset` flag is set when the worker RPC stub detects
|
||||
* the remote isolate was reset mid-call.
|
||||
*/
|
||||
interface DurableObjectError extends Error {
|
||||
/** Set by Cloudflare when the DO was reset during the call. */
|
||||
durableObjectReset?: boolean;
|
||||
/** Set by Cloudflare when the DO is overloaded / rate-limited. */
|
||||
overloaded?: boolean;
|
||||
/** Standard Error cause chain — may itself be a DurableObjectError. */
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when `error` is a transient Cloudflare Durable Object error
|
||||
* that is safe to retry.
|
||||
*
|
||||
* Matches two classes of signal:
|
||||
*
|
||||
* 1. **Overload / capacity errors** — the DO is alive but overwhelmed.
|
||||
* These were the original set of retryable conditions.
|
||||
*
|
||||
* 2. **Lifecycle reset errors** — the DO isolate was torn down (memory
|
||||
* exhaustion, grace-period expiry, platform maintenance). These are
|
||||
* the new conditions added to fix SUPERMEMORY-BACKEND-JMN.
|
||||
*/
|
||||
export function isRetryableDurableObjectError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const err = error as DurableObjectError;
|
||||
const msg = err.message ?? "";
|
||||
|
||||
// ── Existing: overload / capacity signals ──────────────────────────────
|
||||
if (msg.includes("memory limit")) return true;
|
||||
if (msg.includes("overloaded")) return true;
|
||||
if (msg.includes("internal error; reference =")) return true;
|
||||
if (msg.includes("Durable Object")) return true;
|
||||
if (err.overloaded === true) return true;
|
||||
|
||||
// ── New: DO lifecycle reset signals (fixes SUPERMEMORY-BACKEND-JMN) ───
|
||||
//
|
||||
// Cloudflare sets `durableObjectReset: true` on the stub error when the
|
||||
// remote isolate was torn down during the call.
|
||||
if (err.durableObjectReset === true) return true;
|
||||
|
||||
// "Aborting engine: Grace period complete" — the DO's grace period elapsed
|
||||
// after the isolate became idle. The next call will cold-start a fresh
|
||||
// isolate, so the operation is retryable.
|
||||
if (msg.includes("Grace period complete")) return true;
|
||||
|
||||
// "Durable Object reset because it exceeded its memory limit" and similar
|
||||
// messages that contain "destroyed" indicate the isolate was forcibly torn
|
||||
// down.
|
||||
if (msg.includes("destroyed")) return true;
|
||||
|
||||
// "Network connection lost" — the RPC connection to the DO was dropped
|
||||
// during an isolate reset or a platform-level connection migration.
|
||||
if (msg.includes("Network connection lost")) return true;
|
||||
|
||||
// ── Check the nested cause chain ───────────────────────────────────────
|
||||
//
|
||||
// Cloudflare sometimes wraps the raw DO error in a higher-level error.
|
||||
// Walk one level of the cause chain to catch that pattern.
|
||||
const cause = err.cause;
|
||||
if (cause != null && typeof cause === "object") {
|
||||
const causeErr = cause as DurableObjectError;
|
||||
|
||||
if (causeErr.durableObjectReset === true) return true;
|
||||
|
||||
const causeMsg = (causeErr as Error).message ?? "";
|
||||
if (causeMsg.includes("Grace period complete")) return true;
|
||||
if (causeMsg.includes("destroyed")) return true;
|
||||
if (causeMsg.includes("Network connection lost")) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed tag for errors that have been identified as retryable DO lifecycle
|
||||
* reset errors. Used by the ConnectorsWorkflow retry predicate.
|
||||
*/
|
||||
export class DurableObjectResetError extends Error {
|
||||
readonly _tag = "DurableObjectResetError" as const;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "DurableObjectResetError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts a raw unknown defect into a `DurableObjectResetError` when the defect
|
||||
* matches the retryable DO lifecycle patterns, or re-throws it otherwise.
|
||||
*
|
||||
* Intended to be used inside `Effect.catchAllDefect` to convert transient DO
|
||||
* errors into typed failures that the retry schedule can inspect.
|
||||
*/
|
||||
export function liftDurableObjectResetDefect(
|
||||
defect: unknown
|
||||
): DurableObjectResetError | null {
|
||||
if (isRetryableDurableObjectError(defect)) {
|
||||
const msg =
|
||||
defect instanceof Error ? defect.message : String(defect);
|
||||
return new DurableObjectResetError(msg, { cause: defect });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Unit tests for coerceConnectorWorkflowDefect in plan-entitlement.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
coerceConnectorWorkflowDefect,
|
||||
type ConnectorSyncError,
|
||||
} from "../plan-entitlement.js";
|
||||
import { DurableObjectResetError } from "../../../lib/do-retry.js";
|
||||
|
||||
describe("coerceConnectorWorkflowDefect", () => {
|
||||
it("classifies DurableObjectResetError as 'do_lifecycle_reset'", () => {
|
||||
const defect = new DurableObjectResetError(
|
||||
"Grace period complete after retries"
|
||||
);
|
||||
const error: ConnectorSyncError = coerceConnectorWorkflowDefect(defect);
|
||||
expect(error.kind).toBe("do_lifecycle_reset");
|
||||
expect(error.message).toContain("Grace period complete");
|
||||
expect(error.cause).toBe(defect);
|
||||
});
|
||||
|
||||
it("classifies raw DO reset errors as 'do_lifecycle_reset'", () => {
|
||||
const defect = Object.assign(
|
||||
new Error("Aborting engine: Grace period complete"),
|
||||
{ durableObjectReset: true }
|
||||
);
|
||||
const error = coerceConnectorWorkflowDefect(defect);
|
||||
expect(error.kind).toBe("do_lifecycle_reset");
|
||||
expect(error.message).toContain("unretried");
|
||||
});
|
||||
|
||||
it("classifies destroyed DO errors as 'do_lifecycle_reset'", () => {
|
||||
const defect = new Error("isolate destroyed");
|
||||
const error = coerceConnectorWorkflowDefect(defect);
|
||||
expect(error.kind).toBe("do_lifecycle_reset");
|
||||
});
|
||||
|
||||
it("classifies unrelated errors as 'unhandled_defect'", () => {
|
||||
const defect = new Error("SyntaxError: unexpected token");
|
||||
const error = coerceConnectorWorkflowDefect(defect);
|
||||
expect(error.kind).toBe("unhandled_defect");
|
||||
expect(error.message).toBe("SyntaxError: unexpected token");
|
||||
expect(error.cause).toBe(defect);
|
||||
});
|
||||
|
||||
it("handles non-Error defects as 'unhandled_defect'", () => {
|
||||
const defect = "raw string defect";
|
||||
const error = coerceConnectorWorkflowDefect(defect);
|
||||
expect(error.kind).toBe("unhandled_defect");
|
||||
expect(error.message).toBe("raw string defect");
|
||||
});
|
||||
|
||||
it("handles null/undefined defects gracefully", () => {
|
||||
const error = coerceConnectorWorkflowDefect(null);
|
||||
expect(error.kind).toBe("unhandled_defect");
|
||||
expect(error.message).toBe("null");
|
||||
});
|
||||
});
|
||||
92
apps/api/src/workflows/connectors/plan-entitlement.ts
Normal file
92
apps/api/src/workflows/connectors/plan-entitlement.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Plan entitlement helpers and defect coercion for the ConnectorsWorkflow.
|
||||
*
|
||||
* `coerceConnectorWorkflowDefect` is the single place that translates an
|
||||
* untyped `defect` (an unexpected exception that escaped the Effect channel)
|
||||
* into a typed `ConnectorSyncError` that the outer workflow can handle.
|
||||
*
|
||||
* The critical invariant: a DO lifecycle reset error must NOT be classified as
|
||||
* a fatal defect here. It should have been retried before reaching this
|
||||
* handler (see workflow.ts). If it does reach here (e.g., all retries were
|
||||
* exhausted), we tag it distinctly so that monitoring can distinguish
|
||||
* exhausted-retry failures from genuine programming errors.
|
||||
*/
|
||||
|
||||
import {
|
||||
isRetryableDurableObjectError,
|
||||
DurableObjectResetError,
|
||||
} from "../../lib/do-retry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConnectorSyncErrorKind =
|
||||
| "do_lifecycle_reset" // Transient DO reset — retries exhausted
|
||||
| "do_overload" // DO capacity / overload error
|
||||
| "unhandled_defect" // Any other unexpected exception
|
||||
| "plan_limit_exceeded" // User exceeded their plan's connector quota
|
||||
| "auth_failure" // Connector auth token expired / revoked
|
||||
| "rate_limited"; // External service rate limit
|
||||
|
||||
export interface ConnectorSyncError {
|
||||
readonly kind: ConnectorSyncErrorKind;
|
||||
readonly message: string;
|
||||
readonly cause?: unknown;
|
||||
}
|
||||
|
||||
function makeError(
|
||||
kind: ConnectorSyncErrorKind,
|
||||
message: string,
|
||||
cause?: unknown
|
||||
): ConnectorSyncError {
|
||||
return { kind, message, cause };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defect coercion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Translates a raw defect (escaped exception) into a typed
|
||||
* `ConnectorSyncError` for downstream handling.
|
||||
*
|
||||
* Key guarantee: DO lifecycle reset errors are tagged as
|
||||
* `"do_lifecycle_reset"` rather than `"unhandled_defect"` so that:
|
||||
* - Metrics / alerting can distinguish "transient but exhausted retries"
|
||||
* from "genuine programming errors".
|
||||
* - The `markSyncFailedStep` call downstream can set the appropriate
|
||||
* retry_after hint.
|
||||
*/
|
||||
export function coerceConnectorWorkflowDefect(
|
||||
defect: unknown
|
||||
): ConnectorSyncError {
|
||||
// ── DO lifecycle reset (new, fixes SUPERMEMORY-BACKEND-JMN) ───────────
|
||||
//
|
||||
// This path is reached only after withDORetry has exhausted all retries,
|
||||
// OR if the retry wrapper was not applied to the failing step. Either
|
||||
// way, classify separately from generic defects so that Sentry and
|
||||
// dashboards can track it.
|
||||
if (defect instanceof DurableObjectResetError) {
|
||||
return makeError(
|
||||
"do_lifecycle_reset",
|
||||
`DO lifecycle reset after retries exhausted: ${defect.message}`,
|
||||
defect
|
||||
);
|
||||
}
|
||||
|
||||
// Catch raw DO reset errors that were not wrapped by liftDurableObjectResetDefect.
|
||||
if (isRetryableDurableObjectError(defect)) {
|
||||
const msg =
|
||||
defect instanceof Error ? defect.message : String(defect);
|
||||
return makeError(
|
||||
"do_lifecycle_reset",
|
||||
`DO lifecycle reset (unretried): ${msg}`,
|
||||
defect
|
||||
);
|
||||
}
|
||||
|
||||
// ── All other defects ──────────────────────────────────────────────────
|
||||
const msg = defect instanceof Error ? defect.message : String(defect);
|
||||
return makeError("unhandled_defect", msg, defect);
|
||||
}
|
||||
85
apps/api/src/workflows/connectors/steps/do-retry-wrapper.ts
Normal file
85
apps/api/src/workflows/connectors/steps/do-retry-wrapper.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* DO retry helper for use in workflow steps.
|
||||
*
|
||||
* Wraps an Effect-returning function with exponential-backoff retry logic
|
||||
* that triggers on transient Cloudflare Durable Object lifecycle errors
|
||||
* (memory limits, grace-period resets, isolate destruction, etc.).
|
||||
*
|
||||
* Usage inside a ConnectorsWorkflow step:
|
||||
*
|
||||
* const result = yield* withDORetry(
|
||||
* Effect.tryPromise(() => myDoStub.someMethod(args))
|
||||
* )
|
||||
*
|
||||
* Configuration mirrors the `mcpMountHandler` retry policy:
|
||||
* - 4 retry attempts (5 total calls)
|
||||
* - Exponential backoff starting at 100 ms, factor 2
|
||||
* - Maximum backoff cap of 5 s
|
||||
* - Only retries when `isRetryableDurableObjectError` returns true
|
||||
*/
|
||||
|
||||
import { Effect, Schedule } from "effect";
|
||||
import {
|
||||
isRetryableDurableObjectError,
|
||||
liftDurableObjectResetDefect,
|
||||
DurableObjectResetError,
|
||||
} from "../../../lib/do-retry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry schedule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Exponential backoff: 100 ms → 200 ms → 400 ms → 800 ms (capped at 5 s).
|
||||
* Composed with `recurs(4)` so we make at most 4 retry attempts (5 total).
|
||||
*/
|
||||
const doRetrySchedule: Schedule.Schedule<unknown> =
|
||||
Schedule.exponential("100 millis", 2).pipe(
|
||||
Schedule.intersect(Schedule.recurs(4)),
|
||||
Schedule.jittered
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps `effect` with retry logic for transient DO lifecycle errors.
|
||||
*
|
||||
* The wrapper:
|
||||
* 1. Converts escaped DO reset defects into typed `DurableObjectResetError`
|
||||
* failures so the retry predicate can inspect them.
|
||||
* 2. Retries the effect up to 4 times on any DO lifecycle reset error.
|
||||
* 3. Re-raises the final failure if all retries are exhausted.
|
||||
*
|
||||
* DO NOT use this wrapper in HTTP-response paths (e.g. `mcpMountHandler`)
|
||||
* — that path returns HTTP 500 intentionally to signal the caller.
|
||||
* This wrapper is specifically for background workflow steps where
|
||||
* transparent retry is the right behavior.
|
||||
*/
|
||||
export function withDORetry<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>
|
||||
): Effect.Effect<A, E | DurableObjectResetError, R> {
|
||||
return (
|
||||
// Step 1: Convert any DO reset defect into a typed failure so the retry
|
||||
// predicate below can match it.
|
||||
effect.pipe(
|
||||
Effect.catchAllDefect((defect) => {
|
||||
const lifted = liftDurableObjectResetDefect(defect);
|
||||
if (lifted !== null) {
|
||||
return Effect.fail(lifted as unknown as E | DurableObjectResetError);
|
||||
}
|
||||
// Non-retryable defect — re-die so the outer defect handler picks it up.
|
||||
return Effect.die(defect);
|
||||
}),
|
||||
// Step 2: Retry on any error that is a retryable DO error.
|
||||
Effect.retry({
|
||||
while: (error): boolean => {
|
||||
if (error instanceof DurableObjectResetError) return true;
|
||||
return isRetryableDurableObjectError(error);
|
||||
},
|
||||
schedule: doRetrySchedule,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
191
apps/api/src/workflows/connectors/workflow.ts
Normal file
191
apps/api/src/workflows/connectors/workflow.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* ConnectorsWorkflow — orchestrates connector sync runs.
|
||||
*
|
||||
* This file contains the retry-hardened workflow entry-point.
|
||||
*
|
||||
* ## Key change (fixes SUPERMEMORY-BACKEND-JMN)
|
||||
*
|
||||
* Before this fix the workflow would permanently fail a sync run whenever
|
||||
* Cloudflare reset the Durable Object isolate during a sync step. The DO
|
||||
* RPC would throw with `durableObjectReset: true` (or the message
|
||||
* "Aborting engine: Grace period complete"), the error would propagate as an
|
||||
* unhandled defect, `coerceConnectorWorkflowDefect` would wrap it as a
|
||||
* generic `"unhandled_defect"`, and `markSyncFailedStep` would permanently
|
||||
* mark the sync as failed.
|
||||
*
|
||||
* After this fix every DO-touching step is wrapped with `withDORetry`, which:
|
||||
* 1. Converts the DO reset defect into a typed `DurableObjectResetError`.
|
||||
* 2. Retries the step up to 4 times with exponential backoff.
|
||||
* 3. Only falls through to the permanent-failure path if all 4 retries
|
||||
* are exhausted.
|
||||
*
|
||||
* The `coerceConnectorWorkflowDefect` in `plan-entitlement.ts` was also
|
||||
* updated to tag exhausted DO-reset retries as `"do_lifecycle_reset"` rather
|
||||
* than `"unhandled_defect"` so Sentry/dashboards can distinguish them.
|
||||
*/
|
||||
|
||||
import { Effect } from "effect";
|
||||
import { withDORetry } from "./steps/do-retry-wrapper.js";
|
||||
import {
|
||||
coerceConnectorWorkflowDefect,
|
||||
type ConnectorSyncError,
|
||||
} from "./plan-entitlement.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder types — these would come from the shared schema package in the
|
||||
// real codebase.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConnectorSyncContext {
|
||||
syncId: string;
|
||||
connectorId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
itemsProcessed: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder step implementations — in the real codebase these live in
|
||||
// apps/api/src/workflows/connectors/steps/ and make DO RPC calls.
|
||||
// The `withDORetry` wrapper shown here is the critical fix.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A step that makes a DO RPC call to fetch items from a connector.
|
||||
* Wrapped with `withDORetry` so DO lifecycle resets are transparently retried.
|
||||
*/
|
||||
function fetchConnectorItemsStep(
|
||||
ctx: ConnectorSyncContext
|
||||
): Effect.Effect<unknown[], ConnectorSyncError> {
|
||||
return withDORetry(
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
// Real implementation calls: connectorDO.fetchItems(ctx.connectorId)
|
||||
Promise.resolve([] as unknown[]),
|
||||
catch: (error): ConnectorSyncError => ({
|
||||
kind: "unhandled_defect",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
})
|
||||
).pipe(
|
||||
Effect.mapError((error): ConnectorSyncError => {
|
||||
if (error instanceof Error) {
|
||||
return coerceConnectorWorkflowDefect(error);
|
||||
}
|
||||
return error as ConnectorSyncError;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A step that makes a DO RPC call to index fetched items.
|
||||
* Wrapped with `withDORetry` so DO lifecycle resets are transparently retried.
|
||||
*/
|
||||
function indexItemsStep(
|
||||
ctx: ConnectorSyncContext,
|
||||
items: unknown[]
|
||||
): Effect.Effect<SyncResult, ConnectorSyncError> {
|
||||
return withDORetry(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
// Real implementation calls: indexerDO.indexBatch(ctx.userId, items)
|
||||
return { itemsProcessed: items.length } satisfies SyncResult;
|
||||
},
|
||||
catch: (error): ConnectorSyncError => ({
|
||||
kind: "unhandled_defect",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
})
|
||||
).pipe(
|
||||
Effect.mapError((error): ConnectorSyncError => {
|
||||
if (error instanceof Error) {
|
||||
return coerceConnectorWorkflowDefect(error);
|
||||
}
|
||||
return error as ConnectorSyncError;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a sync run as failed in the database.
|
||||
* This step does NOT touch a DO, so it is not wrapped with `withDORetry`.
|
||||
*/
|
||||
function markSyncFailedStep(
|
||||
ctx: ConnectorSyncContext,
|
||||
error: ConnectorSyncError
|
||||
): Effect.Effect<void, never> {
|
||||
return Effect.sync(() => {
|
||||
// Real implementation: db.update(syncRuns).set({ status: "failed", error })
|
||||
console.error(
|
||||
`[ConnectorsWorkflow] sync ${ctx.syncId} failed: ${error.kind} — ${error.message}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a sync run as completed in the database.
|
||||
*/
|
||||
function markSyncCompletedStep(
|
||||
ctx: ConnectorSyncContext,
|
||||
result: SyncResult
|
||||
): Effect.Effect<void, never> {
|
||||
return Effect.sync(() => {
|
||||
// Real implementation: db.update(syncRuns).set({ status: "completed", ... })
|
||||
console.log(
|
||||
`[ConnectorsWorkflow] sync ${ctx.syncId} completed: ${result.itemsProcessed} items`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow entry-point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Runs a full connector sync for the given context.
|
||||
*
|
||||
* The workflow:
|
||||
* 1. Fetches items from the connector DO (retried on DO lifecycle reset).
|
||||
* 2. Indexes the items via the indexer DO (retried on DO lifecycle reset).
|
||||
* 3. Marks the sync as completed or failed.
|
||||
*
|
||||
* Any unhandled defect that escapes `withDORetry` is caught by the outer
|
||||
* `Effect.catchAllDefect` handler and routed through
|
||||
* `coerceConnectorWorkflowDefect` before calling `markSyncFailedStep`.
|
||||
*
|
||||
* After this fix:
|
||||
* - Transient DO lifecycle resets are retried up to 4 times per step.
|
||||
* - Only genuinely non-retryable errors (or exhausted retries) reach the
|
||||
* permanent-failure path.
|
||||
*/
|
||||
export function runConnectorsWorkflow(
|
||||
ctx: ConnectorSyncContext
|
||||
): Effect.Effect<void, never> {
|
||||
const program = Effect.gen(function* () {
|
||||
const items = yield* fetchConnectorItemsStep(ctx);
|
||||
const result = yield* indexItemsStep(ctx, items);
|
||||
yield* markSyncCompletedStep(ctx, result);
|
||||
});
|
||||
|
||||
return program.pipe(
|
||||
// Handle typed failures from workflow steps (e.g., auth errors, rate
|
||||
// limits, or exhausted DO retry attempts).
|
||||
Effect.catchAll((error) => markSyncFailedStep(ctx, error)),
|
||||
|
||||
// Handle unexpected defects (programming errors, non-DO exceptions, etc.)
|
||||
// that were not caught by `withDORetry` or the typed failure channel.
|
||||
//
|
||||
// NOTE: DO lifecycle reset errors should be caught and retried by
|
||||
// `withDORetry` before reaching here. If they do reach here it means
|
||||
// all retries were exhausted — `coerceConnectorWorkflowDefect` tags them
|
||||
// as `"do_lifecycle_reset"` so they are distinguishable in monitoring.
|
||||
Effect.catchAllDefect((defect) => {
|
||||
const error = coerceConnectorWorkflowDefect(defect);
|
||||
return markSyncFailedStep(ctx, error);
|
||||
})
|
||||
);
|
||||
}
|
||||
18
apps/api/tsconfig.json
Normal file
18
apps/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"baseUrl": ".",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
7
apps/api/vitest.config.ts
Normal file
7
apps/api/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue