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", () => {
320320 ),
321321 );
322322 });
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 });
323414});
infra/relay/src/agentActivity/DeliveryAttempts.ts+90-43
......@@ -12,10 +12,19 @@ import { relayDeliveryAttempts } from "../persistence/schema.ts";
1212
1313export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass<DeliveryAttemptRecordPersistenceError>()(
1414 "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 },
1625) {
1726 override get message(): string {
18 return "Failed to persist APNs delivery attempt";
27 return `Failed to persist APNs delivery attempt during ${this.operation}.`;
1928 }
2029}
2130
......@@ -99,30 +108,43 @@ export const make = Effect.gen(function* () {
99108 };
100109
101110 return DeliveryAttempts.of({
102 record: Effect.fn("relay.delivery_attempts.record")(
103 function* (input) {
104 yield* Effect.annotateCurrentSpan({
105 "relay.delivery.kind": input.kind,
106 ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}),
107 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
108 ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}),
109 ...(input.threadId ? { "relay.thread_id": input.threadId } : {}),
110 });
111 record: Effect.fn("relay.delivery_attempts.record")(function* (input) {
112 yield* Effect.annotateCurrentSpan({
113 "relay.delivery.kind": input.kind,
114 ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}),
115 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
116 ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}),
117 ...(input.threadId ? { "relay.thread_id": input.threadId } : {}),
118 });
119 yield* Effect.gen(function* () {
111120 const id = yield* crypto.randomUUIDv4;
112121 const createdAt = DateTime.formatIso(yield* DateTime.now);
113122 yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt));
114 },
115 Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })),
116 ),
117 claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")(
118 function* (input) {
119 yield* Effect.annotateCurrentSpan({
120 "relay.delivery.kind": input.kind,
121 "relay.delivery.job_id": input.sourceJobId,
122 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
123 ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}),
124 ...(input.threadId ? { "relay.thread_id": input.threadId } : {}),
125 });
123 }).pipe(
124 Effect.mapError(
125 (cause) =>
126 new DeliveryAttemptRecordPersistenceError({
127 operation: "record",
128 sourceJobId: input.sourceJobId ?? null,
129 userId: input.userId,
130 environmentId: input.environmentId,
131 threadId: input.threadId,
132 deviceId: input.deviceId,
133 kind: input.kind,
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* () {
126148 const id = yield* crypto.randomUUIDv4;
127149 const now = yield* DateTime.now;
128150 const createdAt = DateTime.formatIso(now);
......@@ -179,26 +201,51 @@ export const make = Effect.gen(function* () {
179201 )
180202 .returning({ id: relayDeliveryAttempts.id });
181203 return reclaimed.length > 0 ? "claimed" : "in_flight";
182 },
183 Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })),
184 ),
185 completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")(
186 function* (input) {
187 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId });
188 const completedAt = DateTime.formatIso(yield* DateTime.now);
189 yield* db
190 .update(relayDeliveryAttempts)
191 .set({
192 createdAt: completedAt,
193 apnsStatus: input.apnsStatus ?? null,
194 apnsReason: input.apnsReason ?? null,
195 apnsId: input.apnsId ?? null,
196 transportError: input.transportError ?? null,
197 })
198 .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId));
199 },
200 Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })),
201 ),
204 }).pipe(
205 Effect.mapError(
206 (cause) =>
207 new DeliveryAttemptRecordPersistenceError({
208 operation: "claim-source-job",
209 sourceJobId: input.sourceJobId,
210 userId: input.userId,
211 environmentId: input.environmentId,
212 threadId: input.threadId,
213 deviceId: input.deviceId,
214 kind: input.kind,
215 cause,
216 }),
217 ),
218 );
219 }),
220 completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")(function* (input) {
221 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId });
222 const completedAt = DateTime.formatIso(yield* DateTime.now);
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 }),
202249 });
203250});
204251