authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 21:26:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:26:25-07:00
log24edd55d9459edfa0a2956094b7868102bba5b6f
tree38439c1eab1f86798b2e6fb45ba14bd01917db74
parent61a0ec9eb0ad12dd4ef054d2dbab8b5eedbdaf06
signature Signed by PGP key B5690EEEBB952194

fix(server): project favicons stop being rescanned every minute (#16206)

Favicons in ProjectEnrichmentService now keep for 15 minutes. Repository identity keeps its 1-minute TTL, so remote changes still show within a minute. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 98 insertions(+), 1 deletions(-)

apps/server/src/project/ProjectEnrichmentService.test.ts+90
......@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as PubSub from "effect/PubSub";
77import * as Ref from "effect/Ref";
8import { TestClock } from "effect/testing";
89
910import * as ProjectEnrichment from "./ProjectEnrichmentService.ts";
1011import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
......@@ -341,3 +342,92 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r
341342 );
342343 }),
343344);
345
346it.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 // Each scan returns a new path, so a wait can tell a rescan from the cached value.
355 resolvePath: (workspaceRoot) =>
356 Ref.updateAndGet(faviconScans, (count) => count + 1).pipe(
357 Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`),
358 ),
359 }),
360 );
361
362 yield* Effect.gen(function* () {
363 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
364 yield* service.getAvailable("/repo");
365 yield* waitForAvailable(
366 service,
367 "/repo",
368 (value) => value.faviconPath === "/repo/favicon-1.svg",
369 );
370
371 // Callers such as the shell stream read projects far more often than this.
372 for (let minute = 1; minute < 15; minute += 1) {
373 yield* TestClock.adjust("1 minute");
374 yield* service.getAvailable("/repo");
375 }
376 assert.equal(yield* Ref.get(faviconScans), 1);
377
378 yield* TestClock.adjust("1 minute");
379 yield* service.getAvailable("/repo");
380 yield* waitForAvailable(
381 service,
382 "/repo",
383 (value) => value.faviconPath === "/repo/favicon-2.svg",
384 );
385 assert.equal(yield* Ref.get(faviconScans), 2);
386 }).pipe(Effect.provide(layer(layerMetadata)));
387 }),
388);
389
390it.effect(
391 "follows repository identity changes within a minute without rescanning the favicon",
392 () =>
393 Effect.gen(function* () {
394 // 0: no repository yet, then one version per remote.
395 const remoteVersion = yield* Ref.make(0);
396 const faviconScans = yield* Ref.make(0);
397 const layerMetadata = Layer.merge(
398 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
399 resolve: (workspaceRoot) =>
400 Ref.get(remoteVersion).pipe(
401 Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))),
402 ),
403 }),
404 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
405 resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)),
406 }),
407 );
408
409 yield* Effect.gen(function* () {
410 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
411 yield* service.getAvailable("/folder");
412 yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved);
413 assert.equal((yield* service.peek("/folder")).repositoryIdentity, null);
414
415 // The folder is published as a repository, then its remote moves to another host.
416 for (const version of [1, 2]) {
417 yield* Ref.set(remoteVersion, version);
418 yield* TestClock.adjust("1 minute");
419 yield* service.getAvailable("/folder");
420 const available = yield* waitForAvailable(
421 service,
422 "/folder",
423 (value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`,
424 );
425 assert.equal(
426 available.repositoryIdentity?.canonicalKey,
427 `example.test/v${version}/folder`,
428 );
429 }
430 assert.equal(yield* Ref.get(faviconScans), 1);
431 }).pipe(Effect.provide(layer(layerMetadata)));
432 }),
433);
apps/server/src/project/ProjectEnrichmentService.ts+8-1
......@@ -19,6 +19,11 @@ const DEFAULT_CACHE_CAPACITY = 512;
1919const DEFAULT_MAX_PENDING = 512;
2020const DEFAULT_CONCURRENCY = 4;
2121const 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.
26const DEFAULT_FAVICON_TTL = Duration.minutes(15);
2227const DEFAULT_FAILURE_TTL = Duration.seconds(5);
2328
2429export 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 );