Skip to content

Commit b884bd8

Browse files
t3dotggclaude
andcommitted
fix(server): project favicons stop being rescanned every minute
PR sync reads enriched project shells about once a minute. Every enrichment entry expired after 1 minute, so each project with a linked PR re-ran its favicon scan (about 20 file checks plus t3.json) every minute. Favicons now keep for 15 minutes. Repository identity keeps its 1-minute TTL, because re-reading it is only a hit on RepositoryIdentityResolver's own cache, so remote changes still show within a minute. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 017c9a0 commit b884bd8

2 files changed

Lines changed: 89 additions & 1 deletion

File tree

‎apps/server/src/project/ProjectEnrichmentService.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55
import * as Layer from "effect/Layer";
66
import * as PubSub from "effect/PubSub";
77
import * as Ref from "effect/Ref";
8+
import { TestClock } from "effect/testing";
89

910
import * as ProjectEnrichment from "./ProjectEnrichmentService.ts";
1011
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
@@ -341,3 +342,83 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r
341342
);
342343
}),
343344
);
345+
346+
it.effect("rescans a favicon only after 15 minutes", () =>
347+
Effect.gen(function* () {
348+
const faviconScans = yield* Ref.make(0);
349+
const layerMetadata = Layer.merge(
350+
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
351+
resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
352+
}),
353+
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
354+
resolvePath: (workspaceRoot) =>
355+
Ref.update(faviconScans, (count) => count + 1).pipe(
356+
Effect.as(`${workspaceRoot}/favicon.svg`),
357+
),
358+
}),
359+
);
360+
361+
yield* Effect.gen(function* () {
362+
const service = yield* ProjectEnrichment.ProjectEnrichmentService;
363+
yield* service.getAvailable("/repo");
364+
yield* waitForAvailable(service, "/repo", (value) => value.faviconPath !== null);
365+
366+
// Callers such as the shell stream read projects far more often than this.
367+
for (let minute = 1; minute < 15; minute += 1) {
368+
yield* TestClock.adjust("1 minute");
369+
yield* service.getAvailable("/repo");
370+
}
371+
assert.equal(yield* Ref.get(faviconScans), 1);
372+
373+
yield* TestClock.adjust("1 minute");
374+
yield* service.getAvailable("/repo");
375+
yield* waitForAvailable(service, "/repo", (value) => value.faviconPath !== null);
376+
assert.equal(yield* Ref.get(faviconScans), 2);
377+
}).pipe(Effect.provide(layer(layerMetadata)));
378+
}),
379+
);
380+
381+
it.effect(
382+
"follows repository identity changes within a minute without rescanning the favicon",
383+
() =>
384+
Effect.gen(function* () {
385+
// 0: no repository yet, then one version per remote.
386+
const remoteVersion = yield* Ref.make(0);
387+
const faviconScans = yield* Ref.make(0);
388+
const layerMetadata = Layer.merge(
389+
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
390+
resolve: (workspaceRoot) =>
391+
Ref.get(remoteVersion).pipe(
392+
Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))),
393+
),
394+
}),
395+
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
396+
resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)),
397+
}),
398+
);
399+
400+
yield* Effect.gen(function* () {
401+
const service = yield* ProjectEnrichment.ProjectEnrichmentService;
402+
yield* service.getAvailable("/folder");
403+
yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved);
404+
assert.equal((yield* service.peek("/folder")).repositoryIdentity, null);
405+
406+
// The folder is published as a repository, then its remote moves to another host.
407+
for (const version of [1, 2]) {
408+
yield* Ref.set(remoteVersion, version);
409+
yield* TestClock.adjust("1 minute");
410+
yield* service.getAvailable("/folder");
411+
const available = yield* waitForAvailable(
412+
service,
413+
"/folder",
414+
(value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`,
415+
);
416+
assert.equal(
417+
available.repositoryIdentity?.canonicalKey,
418+
`example.test/v${version}/folder`,
419+
);
420+
}
421+
assert.equal(yield* Ref.get(faviconScans), 1);
422+
}).pipe(Effect.provide(layer(layerMetadata)));
423+
}),
424+
);

‎apps/server/src/project/ProjectEnrichmentService.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const DEFAULT_CACHE_CAPACITY = 512;
1919
const DEFAULT_MAX_PENDING = 512;
2020
const DEFAULT_CONCURRENCY = 4;
2121
const DEFAULT_SUCCESS_TTL = Duration.minutes(1);
22+
// A favicon scan checks about 20 files. Shell reads (and PR sync through them) ask for every
23+
// linked project about once a minute, and icons rarely change, so a resolved favicon is kept
24+
// longer than the identity, which is only a hit on RepositoryIdentityResolver's own cache.
25+
// Moving a project's workspace root, and deleting it, invalidate both at once.
26+
const DEFAULT_FAVICON_TTL = Duration.minutes(15);
2227
const DEFAULT_FAILURE_TTL = Duration.seconds(5);
2328

2429
export interface ProjectEnrichment {
@@ -41,6 +46,7 @@ export interface ProjectEnrichmentServiceOptions {
4146
/** Worker concurrency for each enrichment field. */
4247
readonly concurrency?: number;
4348
readonly successTtl?: Duration.Input;
49+
readonly faviconTtl?: Duration.Input;
4450
readonly failureTtl?: Duration.Input;
4551
}
4652

@@ -97,6 +103,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* (
97103
const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
98104
const successTtl = options.successTtl ?? DEFAULT_SUCCESS_TTL;
99105
const failureTtl = options.failureTtl ?? DEFAULT_FAILURE_TTL;
106+
const faviconTtl = options.faviconTtl ?? DEFAULT_FAVICON_TTL;
100107

101108
const repositoryIdentityCache = yield* Cache.makeWith(
102109
(workspaceRoot: string) => Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)),
@@ -114,7 +121,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* (
114121
capacity: cacheCapacity,
115122
timeToLive: Exit.match({
116123
onFailure: () => failureTtl,
117-
onSuccess: (result) => (Exit.isSuccess(result) ? successTtl : failureTtl),
124+
onSuccess: (result) => (Exit.isSuccess(result) ? faviconTtl : failureTtl),
118125
}),
119126
},
120127
);

0 commit comments

Comments
 (0)