diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts index 8231fe17afa..2f3d948983d 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts @@ -320,4 +320,95 @@ describe("DeliveryAttempts", () => { ), ); }); + + it.effect("preserves operation context and causes for persistence failures", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + insert: () => ({ + values: (values: Record) => + values.kind === "record" + ? Effect.fail(cause) + : { + onConflictDoNothing: () => ({ + returning: () => Effect.fail(cause), + }), + }, + }), + update: () => ({ + set: () => ({ + where: () => Effect.fail(cause), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const recordError = yield* Effect.flip( + attempts.record({ + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "record", + sourceJobId: "job-1", + token: "apns-token", + }), + ); + const claimError = yield* Effect.flip( + attempts.claimSourceJob({ + userId: "user-2", + environmentId: "env-2", + threadId: "thread-2", + deviceId: "device-2", + kind: "claim", + sourceJobId: "job-2", + token: "apns-token", + }), + ); + const completionError = yield* Effect.flip( + attempts.completeSourceJob({ sourceJobId: "job-3", apnsStatus: 500 }), + ); + + expect(recordError).toMatchObject({ + operation: "record", + sourceJobId: "job-1", + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "record", + cause, + message: "Failed to persist APNs delivery attempt during record.", + }); + expect(claimError).toMatchObject({ + operation: "claim-source-job", + sourceJobId: "job-2", + userId: "user-2", + environmentId: "env-2", + threadId: "thread-2", + deviceId: "device-2", + kind: "claim", + cause, + message: "Failed to persist APNs delivery attempt during claim-source-job.", + }); + expect(completionError).toMatchObject({ + operation: "complete-source-job", + sourceJobId: "job-3", + userId: null, + environmentId: null, + threadId: null, + deviceId: null, + kind: null, + cause, + message: "Failed to persist APNs delivery attempt during complete-source-job.", + }); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); }); diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index 8845588329a..843415abfe8 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -12,10 +12,19 @@ import { relayDeliveryAttempts } from "../persistence/schema.ts"; export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass()( "DeliveryAttemptRecordPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["record", "claim-source-job", "complete-source-job"]), + sourceJobId: Schema.NullOr(Schema.String), + userId: Schema.NullOr(Schema.String), + environmentId: Schema.NullOr(Schema.String), + threadId: Schema.NullOr(Schema.String), + deviceId: Schema.NullOr(Schema.String), + kind: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist APNs delivery attempt"; + return `Failed to persist APNs delivery attempt during ${this.operation}.`; } } @@ -99,30 +108,43 @@ export const make = Effect.gen(function* () { }; return DeliveryAttempts.of({ - record: Effect.fn("relay.delivery_attempts.record")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.delivery.kind": input.kind, - ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), - ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), - ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), - ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - }); + record: Effect.fn("relay.delivery_attempts.record")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + yield* Effect.gen(function* () { const id = yield* crypto.randomUUIDv4; const createdAt = DateTime.formatIso(yield* DateTime.now); yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt)); - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), - claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.delivery.kind": input.kind, - "relay.delivery.job_id": input.sourceJobId, - ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), - ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), - ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - }); + }).pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "record", + sourceJobId: input.sourceJobId ?? null, + userId: input.userId, + environmentId: input.environmentId, + threadId: input.threadId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), + claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + "relay.delivery.job_id": input.sourceJobId, + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + return yield* Effect.gen(function* () { const id = yield* crypto.randomUUIDv4; const now = yield* DateTime.now; const createdAt = DateTime.formatIso(now); @@ -179,26 +201,51 @@ export const make = Effect.gen(function* () { ) .returning({ id: relayDeliveryAttempts.id }); return reclaimed.length > 0 ? "claimed" : "in_flight"; - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), - completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); - const completedAt = DateTime.formatIso(yield* DateTime.now); - yield* db - .update(relayDeliveryAttempts) - .set({ - createdAt: completedAt, - apnsStatus: input.apnsStatus ?? null, - apnsReason: input.apnsReason ?? null, - apnsId: input.apnsId ?? null, - transportError: input.transportError ?? null, - }) - .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)); - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), + }).pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "claim-source-job", + sourceJobId: input.sourceJobId, + userId: input.userId, + environmentId: input.environmentId, + threadId: input.threadId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), + completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); + const completedAt = DateTime.formatIso(yield* DateTime.now); + yield* db + .update(relayDeliveryAttempts) + .set({ + createdAt: completedAt, + apnsStatus: input.apnsStatus ?? null, + apnsReason: input.apnsReason ?? null, + apnsId: input.apnsId ?? null, + transportError: input.transportError ?? null, + }) + .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)) + .pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "complete-source-job", + sourceJobId: input.sourceJobId, + userId: null, + environmentId: null, + threadId: null, + deviceId: null, + kind: null, + cause, + }), + ), + ); + }), }); });