authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:14:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:14:51-07:00
log8ce627ba91a6c4e5b336bc50c2fa06ebf4f4ade6
tree26de032876f2d0eaa6d5b0e57f5d21460b06193d
parent9a78c6f2bab95bdb899dffcefd50fa52f7b61c07
signature Signed by PGP key B5690EEEBB952194

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

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

3 files changed, 208 insertions(+), 30 deletions(-)

apps/mobile/src/state/thread-outbox-manager.ts+78-9
......@@ -1,4 +1,5 @@
1import type { EnvironmentId, MessageId } from "@t3tools/contracts";
1import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
23import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
34
45import {
......@@ -8,6 +9,27 @@ import {
89} from "./thread-outbox-model";
910import type { ThreadOutboxStorage } from "./thread-outbox-storage";
1011
12export 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
1133export interface ThreadOutboxManagerOptions {
1234 readonly registry: AtomRegistry.AtomRegistry;
1335 readonly storage: ThreadOutboxStorage;
......@@ -49,22 +71,51 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
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) {
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) {
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 @@
1import type { MessageId } from "@t3tools/contracts";
1import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
23
34import {
45 decodeQueuedThreadMessage,
......@@ -8,6 +9,22 @@ import {
89
910const THREAD_OUTBOX_DIRECTORY = "thread-outbox";
1011
12export 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
1128export interface ThreadOutboxStorage {
1229 readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>;
1330 readonly write: (message: QueuedThreadMessage) => Promise<void>;
......@@ -32,33 +49,78 @@ async function getMessageFile(messageId: MessageId) {
3249
3350export 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 {
1010 threadOutboxRetryDelayMs,
1111 type QueuedThreadMessage,
1212} from "./thread-outbox-model";
13import { createThreadOutboxManager } from "./thread-outbox-manager";
13import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager";
1414import type { ThreadOutboxStorage } from "./thread-outbox-storage";
1515
1616function queuedMessage(input: {
......@@ -149,9 +149,48 @@ describe("thread outbox", () => {
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", () => {
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", () => {
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 });