Skip to content
Source
Sign inSign up

BackgroundPolicy.ts

297 lines9.5 KB

1import {
2 type AuthSessionId,
3 type BackgroundPolicySnapshot,
4 type BackgroundScope,
5 type ClientActivityLease,
6 type ClientActivityReportInput,
7 type HostPowerSnapshot,
8 type RpcClientId,
9} from "@t3tools/contracts";
10import {
11 getBackgroundActivityPresetSettings,
12 resolveServerBackgroundActivitySettings,
13 type ResolvedBackgroundActivitySettings,
14} from "@t3tools/shared/backgroundActivitySettings";
15import * as DateTime from "effect/DateTime";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Ref from "effect/Ref";
20​
21import * as ServerSettings from "../serverSettings.ts";
22import * as HostPowerMonitor from "./HostPowerMonitor.ts";
23​
24export class BackgroundPolicy extends Context.Service<
25 BackgroundPolicy,
26 {
27 readonly reportClientActivity: (
28 sessionId: AuthSessionId,
29 rpcClientId: RpcClientId,
30 input: ClientActivityReportInput,
31 ) => Effect.Effect<void>;
32 readonly removeRpcClient: (
33 sessionId: AuthSessionId,
34 rpcClientId: RpcClientId,
35 ) => Effect.Effect<void>;
36 readonly snapshot: Effect.Effect<BackgroundPolicySnapshot>;
37 readonly shouldRunScopeWork: (scope: BackgroundScope) => Effect.Effect<boolean>;
38 readonly shouldRunOpportunisticWork: Effect.Effect<boolean>;
39 }
40>()("t3/background/BackgroundPolicy") {}
41​
42const DEFAULT_LEASE_TTL_MS = 45_000;
43const MAX_LEASE_TTL_MS = 120_000;
44export const MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT = 16;
45​
46function scopeKey(scope: BackgroundScope): string {
47 switch (scope.type) {
48 case "server-config":
49 case "diagnostics":
50 return scope.type;
51 case "provider-status":
52 return scope.instanceId ? `${scope.type}:${scope.instanceId}` : scope.type;
53 case "vcs-status":
54 case "git-refs":
55 return `${scope.type}:${scope.cwd}`;
56 case "thread":
57 return `${scope.type}:${scope.threadId}`;
58 }
60​
61function isLeaseActive(lease: ClientActivityLease, now: DateTime.Utc): boolean {
62 return DateTime.isGreaterThan(lease.expiresAt, now);
64​
65function leaseKey(lease: Pick<ClientActivityLease, "sessionId" | "rpcClientId" | "clientId">) {
66 return JSON.stringify([lease.sessionId, lease.rpcClientId, lease.clientId]);
68​
69function upsertClientActivityLease(
70 leases: ReadonlyMap<string, ClientActivityLease>,
71 lease: ClientActivityLease,
72 now: DateTime.Utc,
73): Map<string, ClientActivityLease> {
74 const next = new Map(leases);
75 for (const [key, current] of next) {
76 if (!isLeaseActive(current, now)) {
77 next.delete(key);
78 }
79 }
80​
81 const key = leaseKey(lease);
82 if (!next.has(key)) {
83 let connectionLeaseCount = 0;
84 let oldestConnectionLease:
85 | {
86 readonly key: string;
87 readonly updatedAtMs: number;
88 }
89 | undefined;
90 for (const [currentKey, current] of next) {
91 if (current.sessionId !== lease.sessionId || current.rpcClientId !== lease.rpcClientId) {
92 continue;
93 }
94 connectionLeaseCount += 1;
95 const updatedAtMs = DateTime.toEpochMillis(current.updatedAt);
96 if (oldestConnectionLease === undefined || updatedAtMs < oldestConnectionLease.updatedAtMs) {
97 oldestConnectionLease = { key: currentKey, updatedAtMs };
98 }
99 }
100 if (
101 connectionLeaseCount >= MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT &&
102 oldestConnectionLease !== undefined
103 ) {
104 next.delete(oldestConnectionLease.key);
105 }
106 }
107​
108 next.set(key, lease);
109 return next;
111​
112function isForegroundLease(lease: ClientActivityLease, now: DateTime.Utc): boolean {
113 return isLeaseActive(lease, now) && lease.visible && (lease.focused || lease.recentlyInteracted);
115​
116function leaseHasScope(lease: ClientActivityLease, scope: BackgroundScope): boolean {
117 const key = scopeKey(scope);
118 return lease.scopes.some((leaseScope) => scopeKey(leaseScope) === key);
120​
121function hasThermalPressure(hostPower: HostPowerSnapshot): boolean {
122 return hostPower.thermalState === "serious" || hostPower.thermalState === "critical";
124​
125function isHostConstrained(
126 hostPower: HostPowerSnapshot,
127 settings: ResolvedBackgroundActivitySettings,
128): boolean {
129 if (hostPower.stale) return false;
130 if (
131 hostPower.suspended ||
132 (settings.pauseWhenHostLocked && hostPower.locked === "true") ||
133 hasThermalPressure(hostPower)
134 ) {
135 return true;
136 }
137 if (settings.pauseWhenHostLowPower && hostPower.lowPowerMode === "true") return true;
138 return settings.pauseWhenOnBattery && hostPower.onBattery === "true";
140​
141function isClientConstrained(
142 lease: ClientActivityLease,
143 settings: ResolvedBackgroundActivitySettings,
144): boolean {
145 return settings.pauseWhenClientLowPower && lease.lowPowerMode === "true";
147​
148function leaseMayRunScopedWork(
149 lease: ClientActivityLease,
150 scope: BackgroundScope,
151 now: DateTime.Utc,
152 settings: ResolvedBackgroundActivitySettings,
153): boolean {
154 const activeWithScope = isLeaseActive(lease, now) && leaseHasScope(lease, scope);
155 if (!activeWithScope || isClientConstrained(lease, settings)) {
156 return false;
157 }
158 if (settings.profile === "performance") {
159 return true;
160 }
161 return isForegroundLease(lease, now);
163​
164function computeSnapshot(input: {
165 readonly hostPower: HostPowerSnapshot;
166 readonly leases: ReadonlyMap<string, ClientActivityLease>;
167 readonly now: DateTime.Utc;
168 readonly settings: ResolvedBackgroundActivitySettings;
169 readonly updatedAt: DateTime.Utc;
170}): BackgroundPolicySnapshot {
171 const activeLeases = [...input.leases.values()].filter((lease) =>
172 isLeaseActive(lease, input.now),
173 );
174 const foregroundLeases = activeLeases.filter((lease) => isForegroundLease(lease, input.now));
175 const activeScopeKeys = new Set<string>();
176 for (const lease of activeLeases) {
177 for (const scope of lease.scopes) {
178 activeScopeKeys.add(scopeKey(scope));
179 }
180 }
181​
182 return {
183 hostPower: input.hostPower,
184 leases: activeLeases,
185 activeForegroundLeaseCount: foregroundLeases.length,
186 activeScopeKeys: [...activeScopeKeys].toSorted(),
187 shouldRunOpportunisticWork:
188 foregroundLeases.some((lease) => !isClientConstrained(lease, input.settings)) &&
189 !isHostConstrained(input.hostPower, input.settings),
190 updatedAt: input.updatedAt,
191 };
193​
194/** @public */
195export const make = Effect.fn("background.policy.make")(function* () {
196 const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor;
197 const serverSettings = yield* ServerSettings.ServerSettingsService;
198 const leasesRef = yield* Ref.make(new Map<string, ClientActivityLease>());
199​
200 const backgroundActivitySettings = serverSettings.getSettings.pipe(
201 Effect.map(resolveServerBackgroundActivitySettings),
202 Effect.orElseSucceed(() => getBackgroundActivityPresetSettings("balanced")),
203 );
204​
205 const snapshot = Effect.gen(function* () {
206 const [hostPower, leases, now, settings] = yield* Effect.all([
207 hostPowerMonitor.snapshot,
208 Ref.get(leasesRef),
209 DateTime.now,
210 backgroundActivitySettings,
211 ]);
212 return computeSnapshot({ hostPower, leases, now, settings, updatedAt: now });
213 });
214​
215 const reportClientActivity: BackgroundPolicy["Service"]["reportClientActivity"] = Effect.fn(
216 "BackgroundPolicy.reportClientActivity",
217 )(function* (sessionId, rpcClientId, input) {
218 const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_LEASE_TTL_MS, 1_000), MAX_LEASE_TTL_MS);
219 const now = yield* DateTime.now;
220 const expiresAt = DateTime.add(now, { milliseconds: ttlMs });
221 const lease: ClientActivityLease = {
222 sessionId,
223 rpcClientId,
224 clientId: input.clientId,
225 clientKind: input.clientKind,
226 visible: input.visible,
227 focused: input.focused,
228 recentlyInteracted: input.recentlyInteracted,
229 ...(input.appState !== undefined ? { appState: input.appState } : {}),
230 ...(input.lowPowerMode !== undefined ? { lowPowerMode: input.lowPowerMode } : {}),
231 scopes: input.scopes,
232 updatedAt: now,
233 expiresAt,
234 };
235 yield* Ref.update(leasesRef, (leases) => upsertClientActivityLease(leases, lease, now));
236 });
237​
238 const removeRpcClient: BackgroundPolicy["Service"]["removeRpcClient"] = (
239 sessionId,
240 rpcClientId,
241 ) =>
242 Ref.update(leasesRef, (leases) => {
243 const next = new Map(leases);
244 for (const [key, lease] of next) {
245 if (lease.sessionId === sessionId && lease.rpcClientId === rpcClientId) {
246 next.delete(key);
247 }
248 }
249 return next;
250 });
251​
252 const shouldRunScopeWork: BackgroundPolicy["Service"]["shouldRunScopeWork"] = Effect.fn(
253 "BackgroundPolicy.shouldRunScopeWork",
254 )(function* (scope) {
255 const [current, settings] = yield* Effect.all([snapshot, backgroundActivitySettings]);
256 if (isHostConstrained(current.hostPower, settings)) {
257 return false;
258 }
259 return current.leases.some((lease) =>
260 leaseMayRunScopedWork(lease, scope, current.updatedAt, settings),
261 );
262 });
263​
264 const shouldRunOpportunisticWork = Effect.map(
265 snapshot,
266 (current) => current.shouldRunOpportunisticWork,
267 );
268​
269 yield* Effect.forever(
270 Effect.sleep("15 seconds").pipe(
271 Effect.andThen(
272 Effect.gen(function* () {
273 const now = yield* DateTime.now;
274 yield* Ref.update(leasesRef, (leases) => {
275 const next = new Map(leases);
276 for (const [key, lease] of next) {
277 if (!isLeaseActive(lease, now)) {
278 next.delete(key);
279 }
280 }
281 return next;
282 });
283 }),
284 ),
285 ),
286 ).pipe(Effect.forkScoped);
287​
288 return BackgroundPolicy.of({
289 reportClientActivity,
290 removeRpcClient,
291 snapshot,
292 shouldRunScopeWork,
293 shouldRunOpportunisticWork,
294 });
295});
296​
297export const layer = Layer.effect(BackgroundPolicy, make());