Skip to content

Commit cdaba7f

Browse files
Share thread state idle TTL across client atoms (#3163)
1 parent fbf6263 commit cdaba7f

4 files changed

Lines changed: 49 additions & 14 deletions

File tree

packages/client-runtime/src/state/threadDetail.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";
1515
import { scopeThread } from "./models.ts";
1616
import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
1717
import { parseThreadKey, threadKey } from "./entities.ts";
18+
import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
1819

1920
const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);
2021
const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);
2122
const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);
2223
const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]);
23-
const THREAD_DETAIL_IDLE_TTL_MS = 5 * 60_000;
2424

2525
/**
2626
* Combine detail-only collections with the shell's authoritative thread metadata.
@@ -75,7 +75,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
7575
() => EMPTY_ENVIRONMENT_THREAD_STATE,
7676
),
7777
).pipe(
78-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
78+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
7979
Atom.withLabel(`environment-thread-state-value:${key}`),
8080
);
8181
});
@@ -93,21 +93,21 @@ export function createEnvironmentThreadDetailAtoms<E>(
9393
previousValue = source === null ? null : scopeThread(ref.environmentId, source);
9494
return previousValue;
9595
}).pipe(
96-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
96+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
9797
Atom.withLabel(`environment-thread-detail:${key}`),
9898
);
9999
});
100100

101101
const threadStatusAtomFamily = Atom.family((key: string) =>
102102
Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe(
103-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
103+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
104104
Atom.withLabel(`environment-thread-status:${key}`),
105105
),
106106
);
107107

108108
const threadErrorAtomFamily = Atom.family((key: string) =>
109109
Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe(
110-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
110+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
111111
Atom.withLabel(`environment-thread-error:${key}`),
112112
),
113113
);
@@ -117,7 +117,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
117117
(get): ReadonlyArray<OrchestrationMessage> =>
118118
get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES,
119119
).pipe(
120-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
120+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
121121
Atom.withLabel(`environment-thread-messages:${key}`),
122122
),
123123
);
@@ -127,7 +127,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
127127
(get): ReadonlyArray<OrchestrationThreadActivity> =>
128128
get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES,
129129
).pipe(
130-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
130+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
131131
Atom.withLabel(`environment-thread-activities:${key}`),
132132
),
133133
);
@@ -137,7 +137,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
137137
(get): ReadonlyArray<OrchestrationProposedPlan> =>
138138
get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS,
139139
).pipe(
140-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
140+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
141141
Atom.withLabel(`environment-thread-proposed-plans:${key}`),
142142
),
143143
);
@@ -147,7 +147,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
147147
(get): ReadonlyArray<OrchestrationCheckpointSummary> =>
148148
get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS,
149149
).pipe(
150-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
150+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
151151
Atom.withLabel(`environment-thread-checkpoints:${key}`),
152152
),
153153
);
@@ -156,7 +156,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
156156
Atom.make(
157157
(get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null,
158158
).pipe(
159-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
159+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
160160
Atom.withLabel(`environment-thread-session:${key}`),
161161
),
162162
);
@@ -165,7 +165,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
165165
Atom.make(
166166
(get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null,
167167
).pipe(
168-
Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
168+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
169169
Atom.withLabel(`environment-thread-latest-turn:${key}`),
170170
),
171171
);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Mobile thread routes unmount during back navigation. Retain the stream-backed
2+
// state across short subscriber gaps without keeping every opened thread alive.
3+
export const THREAD_STATE_IDLE_TTL_MS = 5 * 60_000;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+
import { describe, expect, it } from "@effect/vitest";
3+
import * as Layer from "effect/Layer";
4+
import { Atom } from "effect/unstable/reactivity";
5+
6+
import type { EnvironmentRegistry } from "../connection/registry.ts";
7+
import type { EnvironmentCacheStore } from "../platform/persistence.ts";
8+
import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
9+
import { createEnvironmentThreadStateAtoms } from "./threads.ts";
10+
11+
describe("createEnvironmentThreadStateAtoms", () => {
12+
it("retains thread state across short subscriber gaps", () => {
13+
const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
14+
EnvironmentRegistry | EnvironmentCacheStore,
15+
never
16+
>;
17+
const threads = createEnvironmentThreadStateAtoms(runtime);
18+
const environmentId = EnvironmentId.make("environment-1");
19+
const threadId = ThreadId.make("thread-1");
20+
const atom = threads.stateAtom(environmentId, threadId);
21+
22+
expect(atom.idleTTL).toBe(THREAD_STATE_IDLE_TTL_MS);
23+
expect(threads.stateAtom(environmentId, threadId)).toBe(atom);
24+
expect(threads.stateAtom(environmentId, ThreadId.make("thread-2"))).not.toBe(atom);
25+
});
26+
});

packages/client-runtime/src/state/threads.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts";
2121
import { EnvironmentCacheStore } from "../platform/persistence.ts";
2222
import { subscribe } from "../rpc/client.ts";
2323
import { applyThreadDetailEvent } from "./threadReducer.ts";
24+
import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
2425
import { followStreamInEnvironment } from "./runtime.ts";
2526

2627
export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
@@ -249,9 +250,14 @@ export function createEnvironmentThreadStateAtoms<R, E>(
249250
) {
250251
const family = Atom.family((key: string) => {
251252
const { environmentId, threadId } = parseThreadAtomKey(key);
252-
return runtime.atom(threadStateChanges(environmentId, threadId), {
253-
initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
254-
});
253+
return runtime
254+
.atom(threadStateChanges(environmentId, threadId), {
255+
initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
256+
})
257+
.pipe(
258+
Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
259+
Atom.withLabel(`environment-thread-state:${key}`),
260+
);
255261
});
256262

257263
return {

0 commit comments

Comments
 (0)