authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:08:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:08:37-07:00
loge85144a58ce53d30d4d8595788896b37f5b966b8
treeb4bc2eecbb87ab8f80af042790b994d703286ccd
parentdefd9dfe20f8c333b8eab2769e3205dfb560395c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure relay delivery attempt errors (#3312)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 181 insertions(+), 43 deletions(-)

infra/relay/src/agentActivity/DeliveryAttempts.test.ts+91
...@@ -320,4 +320,95 @@ describe("DeliveryAttempts", () => {...@@ -320,4 +320,95 @@ describe("DeliveryAttempts", () => {
320 ),320 ),
321 );321 );
322 });322 });
323
324 it.effect("preserves operation context and causes for persistence failures", () => {
325 const cause = new Error("database unavailable");
326 const fakeDb = {
327 insert: () => ({
328 values: (values: Record<string, unknown>) =>
329 values.kind === "record"
330 ? Effect.fail(cause)
331 : {
332 onConflictDoNothing: () => ({
333 returning: () => Effect.fail(cause),
334 }),
335 },
336 }),
337 update: () => ({
338 set: () => ({
339 where: () => Effect.fail(cause),
340 }),
341 }),
342 } as unknown as RelayDb.RelayDb["Service"];
343
344 return Effect.gen(function* () {
345 const attempts = yield* DeliveryAttempts.DeliveryAttempts;
346 const recordError = yield* Effect.flip(
347 attempts.record({
348 userId: "user-1",
349 environmentId: "env-1",
350 threadId: "thread-1",
351 deviceId: "device-1",
352 kind: "record",
353 sourceJobId: "job-1",
354 token: "apns-token",
355 }),
356 );
357 const claimError = yield* Effect.flip(
358 attempts.claimSourceJob({
359 userId: "user-2",
360 environmentId: "env-2",
361 threadId: "thread-2",
362 deviceId: "device-2",
363 kind: "claim",
364 sourceJobId: "job-2",
365 token: "apns-token",
366 }),
367 );
368 const completionError = yield* Effect.flip(
369 attempts.completeSourceJob({ sourceJobId: "job-3", apnsStatus: 500 }),
370 );
371
372 expect(recordError).toMatchObject({
373 operation: "record",
374 sourceJobId: "job-1",
375 userId: "user-1",
376 environmentId: "env-1",
377 threadId: "thread-1",
378 deviceId: "device-1",
379 kind: "record",
380 cause,
381 message: "Failed to persist APNs delivery attempt during record.",
382 });
383 expect(claimError).toMatchObject({
384 operation: "claim-source-job",
385 sourceJobId: "job-2",
386 userId: "user-2",
387 environmentId: "env-2",
388 threadId: "thread-2",
389 deviceId: "device-2",
390 kind: "claim",
391 cause,
392 message: "Failed to persist APNs delivery attempt during claim-source-job.",
393 });
394 expect(completionError).toMatchObject({
395 operation: "complete-source-job",
396 sourceJobId: "job-3",
397 userId: null,
398 environmentId: null,
399 threadId: null,
400 deviceId: null,
401 kind: null,
402 cause,
403 message: "Failed to persist APNs delivery attempt during complete-source-job.",
404 });
405 }).pipe(
406 Effect.provide(
407 DeliveryAttempts.layer.pipe(
408 Layer.provide(NodeCryptoLayer.layer),
409 Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)),
410 ),
411 ),
412 );
413 });
323});414});
infra/relay/src/agentActivity/DeliveryAttempts.ts+90-43
...@@ -12,10 +12,19 @@ import { relayDeliveryAttempts } from "../persistence/schema.ts";...@@ -12,10 +12,19 @@ import { relayDeliveryAttempts } from "../persistence/schema.ts";
1212
13export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass<DeliveryAttemptRecordPersistenceError>()(13export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass<DeliveryAttemptRecordPersistenceError>()(
14 "DeliveryAttemptRecordPersistenceError",14 "DeliveryAttemptRecordPersistenceError",
15 { cause: Schema.Defect() },15 {
16 operation: Schema.Literals(["record", "claim-source-job", "complete-source-job"]),
17 sourceJobId: Schema.NullOr(Schema.String),
18 userId: Schema.NullOr(Schema.String),
19 environmentId: Schema.NullOr(Schema.String),
20 threadId: Schema.NullOr(Schema.String),
21 deviceId: Schema.NullOr(Schema.String),
22 kind: Schema.NullOr(Schema.String),
23 cause: Schema.Defect(),
24 },
16) {25) {
17 override get message(): string {26 override get message(): string {
18 return "Failed to persist APNs delivery attempt";27 return `Failed to persist APNs delivery attempt during ${this.operation}.`;
19 }28 }
20}29}
2130
...@@ -99,30 +108,43 @@ export const make = Effect.gen(function* () {...@@ -99,30 +108,43 @@ export const make = Effect.gen(function* () {
99 };108 };
100109
101 return DeliveryAttempts.of({110 return DeliveryAttempts.of({
102 record: Effect.fn("relay.delivery_attempts.record")(111 record: Effect.fn("relay.delivery_attempts.record")(function* (input) {
103 function* (input) {112 yield* Effect.annotateCurrentSpan({
104 yield* Effect.annotateCurrentSpan({113 "relay.delivery.kind": input.kind,
105 "relay.delivery.kind": input.kind,114 ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}),
106 ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}),115 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
107 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),116 ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}),
108 ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}),117 ...(input.threadId ? { "relay.thread_id": input.threadId } : {}),
109 ...(input.threadId ? { "relay.thread_id": input.threadId } : {}),118 });
110 });119 yield* Effect.gen(function* () {
111 const id = yield* crypto.randomUUIDv4;120 const id = yield* crypto.randomUUIDv4;
112 const createdAt = DateTime.formatIso(yield* DateTime.now);121 const createdAt = DateTime.formatIso(yield* DateTime.now);
113 yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt));122 yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt));
114 },123 }).pipe(
115 Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })),124 Effect.mapError(
116 ),125 (cause) =>
117 claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")(126 new DeliveryAttemptRecordPersistenceError({
118 function* (input) {127 operation: "record",
119 yield* Effect.annotateCurrentSpan({128 sourceJobId: input.sourceJobId ?? null,
120 "relay.delivery.kind": input.kind,129 userId: input.userId,
121 "relay.delivery.job_id": input.sourceJobId,130 environmentId: input.environmentId,
122 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),131 threadId: input.threadId,
123 ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}),132 deviceId: input.deviceId,
124 ...(input.threadId ? { "relay.thread_id": input.threadId } : {}),133 kind: input.kind,
125 });134 cause,
135 }),
136 ),
137 );
138 }),
139 claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")(function* (input) {
140 yield* Effect.annotateCurrentSpan({
141 "relay.delivery.kind": input.kind,
142 "relay.delivery.job_id": input.sourceJobId,
143 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
144 ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}),
145 ...(input.threadId ? { "relay.thread_id": input.threadId } : {}),
146 });
147 return yield* Effect.gen(function* () {
126 const id = yield* crypto.randomUUIDv4;148 const id = yield* crypto.randomUUIDv4;
127 const now = yield* DateTime.now;149 const now = yield* DateTime.now;
128 const createdAt = DateTime.formatIso(now);150 const createdAt = DateTime.formatIso(now);
...@@ -179,26 +201,51 @@ export const make = Effect.gen(function* () {...@@ -179,26 +201,51 @@ export const make = Effect.gen(function* () {
179 )201 )
180 .returning({ id: relayDeliveryAttempts.id });202 .returning({ id: relayDeliveryAttempts.id });
181 return reclaimed.length > 0 ? "claimed" : "in_flight";203 return reclaimed.length > 0 ? "claimed" : "in_flight";
182 },204 }).pipe(
183 Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })),205 Effect.mapError(
184 ),206 (cause) =>
185 completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")(207 new DeliveryAttemptRecordPersistenceError({
186 function* (input) {208 operation: "claim-source-job",
187 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId });209 sourceJobId: input.sourceJobId,
188 const completedAt = DateTime.formatIso(yield* DateTime.now);210 userId: input.userId,
189 yield* db211 environmentId: input.environmentId,
190 .update(relayDeliveryAttempts)212 threadId: input.threadId,
191 .set({213 deviceId: input.deviceId,
192 createdAt: completedAt,214 kind: input.kind,
193 apnsStatus: input.apnsStatus ?? null,215 cause,
194 apnsReason: input.apnsReason ?? null,216 }),
195 apnsId: input.apnsId ?? null,217 ),
196 transportError: input.transportError ?? null,218 );
197 })219 }),
198 .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId));220 completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")(function* (input) {
199 },221 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId });
200 Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })),222 const completedAt = DateTime.formatIso(yield* DateTime.now);
201 ),223 yield* db
224 .update(relayDeliveryAttempts)
225 .set({
226 createdAt: completedAt,
227 apnsStatus: input.apnsStatus ?? null,
228 apnsReason: input.apnsReason ?? null,
229 apnsId: input.apnsId ?? null,
230 transportError: input.transportError ?? null,
231 })
232 .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId))
233 .pipe(
234 Effect.mapError(
235 (cause) =>
236 new DeliveryAttemptRecordPersistenceError({
237 operation: "complete-source-job",
238 sourceJobId: input.sourceJobId,
239 userId: null,
240 environmentId: null,
241 threadId: null,
242 deviceId: null,
243 kind: null,
244 cause,
245 }),
246 ),
247 );
248 }),
202 });249 });
203});250});
204251