History

[codex] Structure mobile thread outbox failures (#3341)

Julius Marminge committed 4 months ago

Commit 8ce627b1 parent 9a78c6f3 files · +208 −30Browse files

Unified · Split

3 files
  1. apps/mobile/src/state/thread-outbox-manager.ts +78 −9
  2. apps/mobile/src/state/thread-outbox-storage.ts +80 −18
  3. apps/mobile/src/state/thread-outbox.test.ts +50 −3
apps/mobile/src/state/thread-outbox-manager.ts+78−9
@@ -1,4 +1,5 @@
1−import type { EnvironmentId, MessageId } from "@t3tools/contracts";
1+import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
2+import * as Schema from "effect/Schema";
23 import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
34
45 import {
@@ -8,6 +9,27 @@ import { · 3 unmodified lines
89 } from "./thread-outbox-model";
910 import type { ThreadOutboxStorage } from "./thread-outbox-storage";
1011
12+export class ThreadOutboxManagerError extends Schema.TaggedErrorClass<ThreadOutboxManagerError>()(
13+ "ThreadOutboxManagerError",
14+ {
15+ operation: Schema.Literals([
16+ "load",
17+ "enqueue",
18+ "remove",
19+ "clear-environment-load",
20+ "clear-environment-remove",
21+ ]),
22+ environmentId: Schema.NullOr(EnvironmentId),
23+ threadId: Schema.NullOr(ThreadId),
24+ messageId: Schema.NullOr(MessageId),
25+ cause: Schema.Defect(),
26+ },
27+) {
28+ override get message(): string {
29+ return `Thread outbox operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}.`;
30+ }
31+}
32+
1133 export interface ThreadOutboxManagerOptions {
1234 readonly registry: AtomRegistry.AtomRegistry;
1335 readonly storage: ThreadOutboxStorage;
@@ -49,22 +71,51 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { · 35 unmodified lines
4971 loadPromise = serialize(async () => {
5072 const persistedMessages = await options.storage.load();
5173 setMessages([...persistedMessages, ...currentMessages()]);
52− }).catch((error) => {
74+ }).catch((cause) => {
5375 loadPromise = null;
54− warn("[thread-outbox] failed to load persisted messages", error);
76+ warn(
77+ "[thread-outbox] failed to load persisted messages",
78+ new ThreadOutboxManagerError({
79+ operation: "load",
80+ environmentId: null,
81+ threadId: null,
82+ messageId: null,
83+ cause,
84+ }),
85+ );
5586 });
5687 return loadPromise;
5788 };
5889
5990 const enqueue = (message: QueuedThreadMessage): Promise<void> =>
6091 serialize(async () => {
61− await options.storage.write(message);
92+ try {
93+ await options.storage.write(message);
94+ } catch (cause) {
95+ throw new ThreadOutboxManagerError({
96+ operation: "enqueue",
97+ environmentId: message.environmentId,
98+ threadId: message.threadId,
99+ messageId: message.messageId,
100+ cause,
101+ });
102+ }
62103 setMessages([...currentMessages(), message]);
63104 });
64105
65106 const remove = (message: QueuedThreadMessage): Promise<void> =>
66107 serialize(async () => {
67− await options.storage.remove(message);
108+ try {
109+ await options.storage.remove(message);
110+ } catch (cause) {
111+ throw new ThreadOutboxManagerError({
112+ operation: "remove",
113+ environmentId: message.environmentId,
114+ threadId: message.threadId,
115+ messageId: message.messageId,
116+ cause,
117+ });
118+ }
68119 setMessages(
69120 currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
70121 );
@@ -72,8 +123,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { · 1 unmodified line
72123
73124 const clearEnvironment = (environmentId: EnvironmentId): Promise<void> =>
74125 serialize(async () => {
75− const persisted = await options.storage.load().catch((error) => {
76− warn("[thread-outbox] failed to load messages while clearing environment", error);
126+ const persisted = await options.storage.load().catch((cause) => {
127+ warn(
128+ "[thread-outbox] failed to load messages while clearing environment",
129+ new ThreadOutboxManagerError({
130+ operation: "clear-environment-load",
131+ environmentId,
132+ threadId: null,
133+ messageId: null,
134+ cause,
135+ }),
136+ );
77137 return [];
78138 });
79139 const allMessages = flattenQueuedThreadMessages(
@@ -88,8 +148,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { · 8 unmodified lines
88148 try {
89149 await options.storage.remove(message);
90150 removedMessageIds.add(message.messageId);
91− } catch (error) {
92− warn("[thread-outbox] failed to clear persisted message", error);
151+ } catch (cause) {
152+ warn(
153+ "[thread-outbox] failed to clear persisted message",
154+ new ThreadOutboxManagerError({
155+ operation: "clear-environment-remove",
156+ environmentId: message.environmentId,
157+ threadId: message.threadId,
158+ messageId: message.messageId,
159+ cause,
160+ }),
161+ );
93162 }
94163 }),
95164 );
apps/mobile/src/state/thread-outbox-storage.ts+80−18
@@ -1,4 +1,5 @@
1−import type { MessageId } from "@t3tools/contracts";
1+import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
2+import * as Schema from "effect/Schema";
23
34 import {
45 decodeQueuedThreadMessage,
@@ -8,6 +9,22 @@ import { · 3 unmodified lines
89
910 const THREAD_OUTBOX_DIRECTORY = "thread-outbox";
1011
12+export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()(
13+ "ThreadOutboxStorageError",
14+ {
15+ operation: Schema.Literals(["load", "read-message", "write", "remove"]),
16+ environmentId: Schema.NullOr(EnvironmentId),
17+ threadId: Schema.NullOr(ThreadId),
18+ messageId: Schema.NullOr(MessageId),
19+ fileName: Schema.NullOr(Schema.String),
20+ cause: Schema.Defect(),
21+ },
22+) {
23+ override get message(): string {
24+ return `Thread outbox storage operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}, file ${this.fileName ?? "unknown"}.`;
25+ }
26+}
27+
1128 export interface ThreadOutboxStorage {
1229 readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>;
1330 readonly write: (message: QueuedThreadMessage) => Promise<void>;
@@ -32,33 +49,78 @@ async function getMessageFile(messageId: MessageId) { · 18 unmodified lines
3249
3350 export const expoThreadOutboxStorage: ThreadOutboxStorage = {
3451 load: async () => {
35− const { File } = await import("expo-file-system");
36− const directory = await getOutboxDirectory();
3752 const messages: QueuedThreadMessage[] = [];
53+ try {
54+ const { File } = await import("expo-file-system");
55+ const directory = await getOutboxDirectory();
3856
39− for (const entry of directory.list()) {
40− if (!(entry instanceof File) || !entry.name.endsWith(".json")) {
41− continue;
42− }
43− try {
44− messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown));
45− } catch (error) {
46− console.warn("[thread-outbox] ignored invalid persisted message", entry.name, error);
57+ for (const entry of directory.list()) {
58+ if (!(entry instanceof File) || !entry.name.endsWith(".json")) {
59+ continue;
60+ }
61+ try {
62+ messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown));
63+ } catch (cause) {
64+ console.warn(
65+ "[thread-outbox] ignored invalid persisted message",
66+ new ThreadOutboxStorageError({
67+ operation: "read-message",
68+ environmentId: null,
69+ threadId: null,
70+ messageId: null,
71+ fileName: entry.name,
72+ cause,
73+ }),
74+ );
75+ }
4776 }
77+ } catch (cause) {
78+ throw new ThreadOutboxStorageError({
79+ operation: "load",
80+ environmentId: null,
81+ threadId: null,
82+ messageId: null,
83+ fileName: null,
84+ cause,
85+ });
4886 }
4987 return messages;
5088 },
5189 write: async (message) => {
52− const file = await getMessageFile(message.messageId);
53− if (!file.exists) {
54− file.create({ intermediates: true, overwrite: true });
90+ const fileName = messageFileName(message.messageId);
91+ try {
92+ const file = await getMessageFile(message.messageId);
93+ if (!file.exists) {
94+ file.create({ intermediates: true, overwrite: true });
95+ }
96+ file.write(JSON.stringify(encodeQueuedThreadMessage(message)));
97+ } catch (cause) {
98+ throw new ThreadOutboxStorageError({
99+ operation: "write",
100+ environmentId: message.environmentId,
101+ threadId: message.threadId,
102+ messageId: message.messageId,
103+ fileName,
104+ cause,
105+ });
55106 }
56− file.write(JSON.stringify(encodeQueuedThreadMessage(message)));
57107 },
58108 remove: async (message) => {
59− const file = await getMessageFile(message.messageId);
60− if (file.exists) {
61− file.delete();
109+ const fileName = messageFileName(message.messageId);
110+ try {
111+ const file = await getMessageFile(message.messageId);
112+ if (file.exists) {
113+ file.delete();
114+ }
115+ } catch (cause) {
116+ throw new ThreadOutboxStorageError({
117+ operation: "remove",
118+ environmentId: message.environmentId,
119+ threadId: message.threadId,
120+ messageId: message.messageId,
121+ fileName,
122+ cause,
123+ });
62124 }
63125 },
64126 };
apps/mobile/src/state/thread-outbox.test.ts+50−3
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 threadOutboxRetryDelayMs,
1111 type QueuedThreadMessage,
1212 } from "./thread-outbox-model";
13−import { createThreadOutboxManager } from "./thread-outbox-manager";
13+import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager";
1414 import type { ThreadOutboxStorage } from "./thread-outbox-storage";
1515
1616 function queuedMessage(input: {
@@ -149,9 +149,48 @@ describe("thread outbox", () => { · 132 unmodified lines
149149 registry.dispose();
150150 });
151151
152+ it("reports structured load failures and permits a retry", async () => {
153+ const registry = AtomRegistry.make();
154+ const loadCause = new Error("storage unavailable");
155+ const warnings: Array<{ message: string; error: unknown }> = [];
156+ let loadCalls = 0;
157+ const manager = createThreadOutboxManager({
158+ registry,
159+ storage: {
160+ load: async () => {
161+ loadCalls += 1;
162+ if (loadCalls === 1) throw loadCause;
163+ return [];
164+ },
165+ write: async () => undefined,
166+ remove: async () => undefined,
167+ },
168+ warn: (message, error) => warnings.push({ message, error }),
169+ });
170+
171+ await manager.load();
172+ expect(warnings).toEqual([
173+ {
174+ message: "[thread-outbox] failed to load persisted messages",
175+ error: new ThreadOutboxManagerError({
176+ operation: "load",
177+ environmentId: null,
178+ threadId: null,
179+ messageId: null,
180+ cause: loadCause,
181+ }),
182+ },
183+ ]);
184+
185+ await manager.load();
186+ expect(loadCalls).toBe(2);
187+ registry.dispose();
188+ });
189+
152190 it("keeps atom state aligned with durable writes and removals", async () => {
153191 const registry = AtomRegistry.make();
154192 const stored = new Map<MessageId, QueuedThreadMessage>();
193+ const removalCause = new Error("remove failed");
155194 let failRemoval = true;
156195 const storage: ThreadOutboxStorage = {
157196 load: async () => [...stored.values()],
@@ -160,7 +199,7 @@ describe("thread outbox", () => { · 2 unmodified lines
160199 },
161200 remove: async (message) => {
162201 if (failRemoval) {
163− throw new Error("remove failed");
202+ throw removalCause;
164203 }
165204 stored.delete(message.messageId);
166205 },
@@ -176,7 +215,15 @@ describe("thread outbox", () => { · 9 unmodified lines
176215 "environment-1:thread-1": [message],
177216 });
178217
179− await expect(manager.remove(message)).rejects.toThrow("remove failed");
218+ await expect(manager.remove(message)).rejects.toEqual(
219+ new ThreadOutboxManagerError({
220+ operation: "remove",
221+ environmentId: message.environmentId,
222+ threadId: message.threadId,
223+ messageId: message.messageId,
224+ cause: removalCause,
225+ }),
226+ );
180227 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
181228 "environment-1:thread-1": [message],
182229 });