Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/server/src/project/ProjectEnrichmentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import { TestClock } from "effect/testing";

import * as ProjectEnrichment from "./ProjectEnrichmentService.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
Expand Down Expand Up @@ -341,3 +342,92 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r
);
}),
);

it.effect("rescans a favicon only after 15 minutes", () =>
Effect.gen(function* () {
const faviconScans = yield* Ref.make(0);
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
// Each scan returns a new path, so a wait can tell a rescan from the cached value.
resolvePath: (workspaceRoot) =>
Ref.updateAndGet(faviconScans, (count) => count + 1).pipe(
Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`),
),
}),
);

yield* Effect.gen(function* () {
const service = yield* ProjectEnrichment.ProjectEnrichmentService;
yield* service.getAvailable("/repo");
yield* waitForAvailable(
service,
"/repo",
(value) => value.faviconPath === "/repo/favicon-1.svg",
);

// Callers such as the shell stream read projects far more often than this.
for (let minute = 1; minute < 15; minute += 1) {
yield* TestClock.adjust("1 minute");
yield* service.getAvailable("/repo");
}
assert.equal(yield* Ref.get(faviconScans), 1);

yield* TestClock.adjust("1 minute");
yield* service.getAvailable("/repo");
yield* waitForAvailable(
service,
"/repo",
(value) => value.faviconPath === "/repo/favicon-2.svg",
);
assert.equal(yield* Ref.get(faviconScans), 2);
}).pipe(Effect.provide(layer(layerMetadata)));
}),
);

it.effect(
"follows repository identity changes within a minute without rescanning the favicon",
() =>
Effect.gen(function* () {
// 0: no repository yet, then one version per remote.
const remoteVersion = yield* Ref.make(0);
const faviconScans = yield* Ref.make(0);
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
resolve: (workspaceRoot) =>
Ref.get(remoteVersion).pipe(
Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))),
),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)),
}),
);

yield* Effect.gen(function* () {
const service = yield* ProjectEnrichment.ProjectEnrichmentService;
yield* service.getAvailable("/folder");
yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved);
assert.equal((yield* service.peek("/folder")).repositoryIdentity, null);

// The folder is published as a repository, then its remote moves to another host.
for (const version of [1, 2]) {
yield* Ref.set(remoteVersion, version);
yield* TestClock.adjust("1 minute");
yield* service.getAvailable("/folder");
const available = yield* waitForAvailable(
service,
"/folder",
(value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`,
);
assert.equal(
available.repositoryIdentity?.canonicalKey,
`example.test/v${version}/folder`,
);
}
assert.equal(yield* Ref.get(faviconScans), 1);
}).pipe(Effect.provide(layer(layerMetadata)));
}),
);
9 changes: 8 additions & 1 deletion apps/server/src/project/ProjectEnrichmentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const DEFAULT_CACHE_CAPACITY = 512;
const DEFAULT_MAX_PENDING = 512;
const DEFAULT_CONCURRENCY = 4;
const DEFAULT_SUCCESS_TTL = Duration.minutes(1);
// A favicon scan checks about 20 files. Shell reads (and PR sync through them) ask for every
// linked project about once a minute, and icons rarely change, so a resolved favicon is kept
// longer than the identity, which is only a hit on RepositoryIdentityResolver's own cache.
// Moving a project's workspace root, and deleting it, invalidate both at once.
const DEFAULT_FAVICON_TTL = Duration.minutes(15);
const DEFAULT_FAILURE_TTL = Duration.seconds(5);

export interface ProjectEnrichment {
Expand All @@ -41,6 +46,7 @@ export interface ProjectEnrichmentServiceOptions {
/** Worker concurrency for each enrichment field. */
readonly concurrency?: number;
readonly successTtl?: Duration.Input;
readonly faviconTtl?: Duration.Input;
readonly failureTtl?: Duration.Input;
}

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

const repositoryIdentityCache = yield* Cache.makeWith(
(workspaceRoot: string) => Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)),
Expand All @@ -114,7 +121,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* (
capacity: cacheCapacity,
timeToLive: Exit.match({
onFailure: () => failureTtl,
onSuccess: (result) => (Exit.isSuccess(result) ? successTtl : failureTtl),
onSuccess: (result) => (Exit.isSuccess(result) ? faviconTtl : failureTtl),
}),
},
);
Expand Down
Loading