| author | |
| committer | |
| log | 33242d0164d47c6935ba5c7725296539384de65d |
| tree | 99f5d68ae0d84de2d3cbba4244302438c2c6a0d9 |
| parent | de37964db26bd3260d253f138bfde2ac60fe254e |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 275 insertions(+), 28 deletions(-)
apps/server/src/pullRequest/PullRequestReadCache.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { PullRequestOperationError } from "@t3tools/contracts"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as TestClock from "effect/testing/TestClock"; | ||
| 10 | import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; | ||
| 11 | import * as Persistence from "effect/unstable/persistence/Persistence"; | ||
| 12 | import * as PullRequestReadCache from "./PullRequestReadCache.ts"; | ||
| 13 | |||
| 14 | const cacheLayer = (directory: string) => | ||
| 15 | PullRequestReadCache.make.pipe( | ||
| 16 | Effect.provide( | ||
| 17 | Persistence.layerKvs.pipe(Layer.provideMerge(KeyValueStore.layerFileSystem(directory))), | ||
| 18 | ), | ||
| 19 | ); | ||
| 20 | |||
| 21 | it.layer(NodeServices.layer)("PR filesystem cache", (it) => { | ||
| 22 | it.effect("reuses files after restart and respects the original expiry", () => | ||
| 23 | Effect.gen(function* () { | ||
| 24 | const fs = yield* FileSystem.FileSystem; | ||
| 25 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); | ||
| 26 | let reads = 0; | ||
| 27 | const lookup = Effect.sync(() => String(++reads)); | ||
| 28 | const first = yield* cacheLayer(directory); | ||
| 29 | const key = "long/repository/key".repeat(100); | ||
| 30 | assert.strictEqual(yield* first.get(key, lookup), "1"); | ||
| 31 | yield* TestClock.adjust("59 seconds"); | ||
| 32 | const restarted = yield* cacheLayer(directory); | ||
| 33 | assert.strictEqual(yield* restarted.get(key, lookup), "1"); | ||
| 34 | yield* TestClock.adjust("1 second"); | ||
| 35 | assert.strictEqual(yield* restarted.get(key, lookup), "2"); | ||
| 36 | assert.strictEqual(reads, 2); | ||
| 37 | assert.strictEqual((yield* fs.readDirectory(directory)).length, 1); | ||
| 38 | }), | ||
| 39 | ); | ||
| 40 | |||
| 41 | it.effect("clears in-flight reads before a new service can reuse them", () => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const fs = yield* FileSystem.FileSystem; | ||
| 44 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); | ||
| 45 | const started = yield* Deferred.make<void>(); | ||
| 46 | const release = yield* Deferred.make<void>(); | ||
| 47 | const cache = yield* cacheLayer(directory); | ||
| 48 | const read = yield* cache | ||
| 49 | .get( | ||
| 50 | "summary", | ||
| 51 | Deferred.succeed(started, undefined).pipe( | ||
| 52 | Effect.andThen(Deferred.await(release)), | ||
| 53 | Effect.as("old"), | ||
| 54 | ), | ||
| 55 | ) | ||
| 56 | .pipe(Effect.forkChild); | ||
| 57 | yield* Deferred.await(started); | ||
| 58 | const invalidate = yield* cache.invalidate.pipe(Effect.forkChild({ startImmediately: true })); | ||
| 59 | yield* Deferred.succeed(release, undefined); | ||
| 60 | yield* Fiber.join(read); | ||
| 61 | yield* Fiber.join(invalidate); | ||
| 62 | const restarted = yield* cacheLayer(directory); | ||
| 63 | assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new")), "new"); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | |||
| 67 | it.effect("does not persist failed GitHub reads", () => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const fs = yield* FileSystem.FileSystem; | ||
| 70 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); | ||
| 71 | const cache = yield* cacheLayer(directory); | ||
| 72 | const error = new PullRequestOperationError({ operation: "summary", detail: "unavailable" }); | ||
| 73 | yield* cache.get("summary", Effect.fail(error)).pipe(Effect.flip); | ||
| 74 | const restarted = yield* cacheLayer(directory); | ||
| 75 | assert.strictEqual(yield* restarted.get("summary", Effect.succeed("recovered")), "recovered"); | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | }); | ||
apps/server/src/pullRequest/PullRequestReadCache.ts created+125| ... | @@ -0,0 +1,125 @@ | ||
| 1 | import * as Cache from "effect/Cache"; | ||
| 2 | import * as Clock from "effect/Clock"; | ||
| 3 | import * as Equal from "effect/Equal"; | ||
| 4 | import * as Hash from "effect/Hash"; | ||
| 5 | import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts"; | ||
| 6 | import * as Crypto from "effect/Crypto"; | ||
| 7 | import * as Encoding from "effect/Encoding"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import * as Duration from "effect/Duration"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Exit from "effect/Exit"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Path from "effect/Path"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Semaphore from "effect/Semaphore"; | ||
| 17 | import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; | ||
| 18 | import * as Persistable from "effect/unstable/persistence/Persistable"; | ||
| 19 | import * as PersistedCache from "effect/unstable/persistence/PersistedCache"; | ||
| 20 | import * as Persistence from "effect/unstable/persistence/Persistence"; | ||
| 21 | import { ServerConfig } from "../config.ts"; | ||
| 22 | |||
| 23 | const CONCURRENT_READS = 512; | ||
| 24 | type ReadError = PullRequestOperationError | PullRequestUnavailableError; | ||
| 25 | |||
| 26 | class Read extends Persistable.Class<{ | ||
| 27 | payload: { key: string; lookup: Effect.Effect<string, ReadError> }; | ||
| 28 | }>()("PullRequestRead", { | ||
| 29 | primaryKey: ({ key }) => key, | ||
| 30 | success: Schema.Struct({ payload: Schema.String, expiresAt: Schema.Finite }), | ||
| 31 | error: Schema.Union([PullRequestOperationError, PullRequestUnavailableError]), | ||
| 32 | }) { | ||
| 33 | [Equal.symbol](that: unknown): boolean { | ||
| 34 | return that instanceof Read && that.key === this.key; | ||
| 35 | } | ||
| 36 | [Hash.symbol](): number { | ||
| 37 | return Hash.string(this.key); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | export class PullRequestReadCache extends Context.Service< | ||
| 42 | PullRequestReadCache, | ||
| 43 | { | ||
| 44 | readonly get: ( | ||
| 45 | key: string, | ||
| 46 | lookup: Effect.Effect<string, ReadError>, | ||
| 47 | ) => Effect.Effect<string, ReadError>; | ||
| 48 | readonly invalidate: Effect.Effect<void>; | ||
| 49 | } | ||
| 50 | >()("t3/pullRequest/PullRequestReadCache") {} | ||
| 51 | |||
| 52 | export const make = Effect.gen(function* () { | ||
| 53 | const backing = yield* KeyValueStore.KeyValueStore; | ||
| 54 | const crypto = yield* Crypto.Crypto; | ||
| 55 | const clock = yield* Clock.Clock; | ||
| 56 | let enabled = true; | ||
| 57 | const lock = yield* Semaphore.make(CONCURRENT_READS); | ||
| 58 | const timeToLive: Persistable.TimeToLiveFn<Read> = (exit) => | ||
| 59 | Exit.isSuccess(exit) | ||
| 60 | ? Duration.millis(Math.max(0, exit.value.expiresAt - clock.currentTimeMillisUnsafe())) | ||
| 61 | : Duration.zero; | ||
| 62 | const cache = yield* PersistedCache.make( | ||
| 63 | (request: Read) => | ||
| 64 | request.lookup.pipe( | ||
| 65 | Effect.map((payload) => ({ payload, expiresAt: clock.currentTimeMillisUnsafe() + 60_000 })), | ||
| 66 | ), | ||
| 67 | { | ||
| 68 | storeId: "pr-v2", | ||
| 69 | timeToLive, | ||
| 70 | inMemoryTTL: timeToLive, | ||
| 71 | inMemoryCapacity: CONCURRENT_READS, | ||
| 72 | }, | ||
| 73 | ); | ||
| 74 | return PullRequestReadCache.of({ | ||
| 75 | get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup) { | ||
| 76 | if (!enabled) return yield* lookup; | ||
| 77 | const digest = yield* crypto | ||
| 78 | .digest("SHA-256", new TextEncoder().encode(key)) | ||
| 79 | .pipe(Effect.option); | ||
| 80 | if (Option.isNone(digest)) return yield* lookup; | ||
| 81 | const read = yield* Effect.cached(lookup); | ||
| 82 | return yield* cache | ||
| 83 | .get(new Read({ key: Encoding.encodeHex(digest.value), lookup: read })) | ||
| 84 | .pipe( | ||
| 85 | Effect.map((result) => result.payload), | ||
| 86 | Effect.catchTags({ | ||
| 87 | PersistenceError: () => read, | ||
| 88 | SchemaError: () => read, | ||
| 89 | }), | ||
| 90 | Effect.uninterruptible, | ||
| 91 | lock.withPermits(1), | ||
| 92 | ); | ||
| 93 | }), | ||
| 94 | // Let existing reads finish before clearing, so they cannot repopulate stale entries. | ||
| 95 | invalidate: Cache.invalidateAll(cache.inMemory).pipe( | ||
| 96 | Effect.andThen(backing.clear), | ||
| 97 | Effect.catch(() => { | ||
| 98 | enabled = false; | ||
| 99 | return Effect.logWarning("PR cache disabled after clearing failed"); | ||
| 100 | }), | ||
| 101 | lock.withPermits(CONCURRENT_READS), | ||
| 102 | ), | ||
| 103 | }); | ||
| 104 | }); | ||
| 105 | |||
| 106 | export const layer = Layer.unwrap( | ||
| 107 | Effect.gen(function* () { | ||
| 108 | const config = yield* ServerConfig; | ||
| 109 | const path = yield* Path.Path; | ||
| 110 | return Layer.effect(PullRequestReadCache, make).pipe( | ||
| 111 | Layer.provide(Persistence.layerKvs), | ||
| 112 | Layer.provide( | ||
| 113 | KeyValueStore.layerFileSystem( | ||
| 114 | path.join(config.providerStatusCacheDir, "pull-requests"), | ||
| 115 | ).pipe( | ||
| 116 | Layer.catch(() => | ||
| 117 | Layer.effectDiscard( | ||
| 118 | Effect.logWarning("PR cache directory unavailable; using memory cache"), | ||
| 119 | ).pipe(Layer.provideMerge(KeyValueStore.layerMemory)), | ||
| 120 | ), | ||
| 121 | ), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | }), | ||
| 125 | ); | ||
apps/server/src/pullRequest/PullRequestService.test.ts+9| ... | @@ -1,3 +1,6 @@ | ... | @@ -1,3 +1,6 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; | ||
| 3 | import * as Persistence from "effect/unstable/persistence/Persistence"; | ||
| 1 | import { assert, it } from "@effect/vitest"; | 4 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; | 5 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| ... | @@ -24,6 +27,7 @@ import { | ... | @@ -24,6 +27,7 @@ import { |
| 24 | } from "./PullRequestProvider.ts"; | 27 | } from "./PullRequestProvider.ts"; |
| 25 | import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; | 28 | import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; |
| 26 | import * as PullRequestService from "./PullRequestService.ts"; | 29 | import * as PullRequestService from "./PullRequestService.ts"; |
| 30 | import * as PullRequestReadCache from "./PullRequestReadCache.ts"; | ||
| 27 | 31 | ||
| 28 | function project(input: { | 32 | function project(input: { |
| 29 | readonly id: string; | 33 | readonly id: string; |
| ... | @@ -198,6 +202,11 @@ function makeService(input: { | ... | @@ -198,6 +202,11 @@ function makeService(input: { |
| 198 | }), | 202 | }), |
| 199 | }), | 203 | }), |
| 200 | SourceControlRateLimit.layer, | 204 | SourceControlRateLimit.layer, |
| 205 | Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( | ||
| 206 | Layer.provide(Persistence.layerKvs), | ||
| 207 | Layer.provide(KeyValueStore.layerMemory), | ||
| 208 | Layer.provide(NodeServices.layer), | ||
| 209 | ), | ||
| 201 | ), | 210 | ), |
| 202 | ), | 211 | ), |
| 203 | ); | 212 | ); |
apps/server/src/pullRequest/PullRequestService.ts+61-28| ... | @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as PubSub from "effect/PubSub"; | 13 | import * as PubSub from "effect/PubSub"; |
| 14 | import * as Option from "effect/Option"; | ||
| 14 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 15 | import type * as Scope from "effect/Scope"; | 16 | import type * as Scope from "effect/Scope"; |
| 16 | import * as Stream from "effect/Stream"; | 17 | import * as Stream from "effect/Stream"; |
| ... | @@ -50,8 +51,8 @@ import { | ... | @@ -50,8 +51,8 @@ import { |
| 50 | type PullRequestLabelCandidateList, | 51 | type PullRequestLabelCandidateList, |
| 51 | type PullRequestLabelChangeInput, | 52 | type PullRequestLabelChangeInput, |
| 52 | type PullRequestSubmitReviewInput, | 53 | type PullRequestSubmitReviewInput, |
| 53 | type PullRequestStack, | 54 | PullRequestStack, |
| 54 | type PullRequestSummary, | 55 | PullRequestSummary, |
| 55 | type PullRequestThreadReplyInput, | 56 | type PullRequestThreadReplyInput, |
| 56 | type PullRequestThreadResolutionInput, | 57 | type PullRequestThreadResolutionInput, |
| 57 | type PullRequestThreadCommentsInput, | 58 | type PullRequestThreadCommentsInput, |
| ... | @@ -71,6 +72,7 @@ import { | ... | @@ -71,6 +72,7 @@ import { |
| 71 | type PullRequestProviderApi, | 72 | type PullRequestProviderApi, |
| 72 | PullRequestProviderError, | 73 | PullRequestProviderError, |
| 73 | } from "./PullRequestProvider.ts"; | 74 | } from "./PullRequestProvider.ts"; |
| 75 | import * as PullRequestReadCache from "./PullRequestReadCache.ts"; | ||
| 74 | import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; | 76 | import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; |
| 75 | 77 | ||
| 76 | export interface PullRequestMergeEvent extends PullRequestRef { | 78 | export interface PullRequestMergeEvent extends PullRequestRef { |
| ... | @@ -114,7 +116,6 @@ const REPOSITORY_SEARCH_CHUNK = 100; | ... | @@ -114,7 +116,6 @@ const REPOSITORY_SEARCH_CHUNK = 100; |
| 114 | * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. | 116 | * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. |
| 115 | */ | 117 | */ |
| 116 | const LIST_CACHE_TTL = Duration.seconds(30); | 118 | const LIST_CACHE_TTL = Duration.seconds(30); |
| 117 | const SUMMARY_CACHE_TTL = Duration.seconds(60); | ||
| 118 | const DETAIL_CACHE_TTL = Duration.seconds(15); | 119 | const DETAIL_CACHE_TTL = Duration.seconds(15); |
| 119 | const DIFF_CACHE_TTL = Duration.seconds(60); | 120 | const DIFF_CACHE_TTL = Duration.seconds(60); |
| 120 | /** A commit is content-addressed, so its own diff cannot change under its key. */ | 121 | /** A commit is content-addressed, so its own diff cannot change under its key. */ |
| ... | @@ -535,6 +536,7 @@ export const make = Effect.gen(function* () { | ... | @@ -535,6 +536,7 @@ export const make = Effect.gen(function* () { |
| 535 | const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | 536 | const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 536 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; | 537 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 537 | const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; | 538 | const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; |
| 539 | const readCache = yield* PullRequestReadCache.PullRequestReadCache; | ||
| 538 | 540 | ||
| 539 | const refineUnknownProjectKinds = ( | 541 | const refineUnknownProjectKinds = ( |
| 540 | projects: ReadonlyArray<OrchestrationProjectShell>, | 542 | projects: ReadonlyArray<OrchestrationProjectShell>, |
| ... | @@ -2333,18 +2335,49 @@ export const make = Effect.gen(function* () { | ... | @@ -2333,18 +2335,49 @@ export const make = Effect.gen(function* () { |
| 2333 | }; | 2335 | }; |
| 2334 | }; | 2336 | }; |
| 2335 | 2337 | ||
| 2336 | const summaryCache = yield* Cache.makeWith( | 2338 | const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* <A>( |
| 2337 | (key: string) => { | 2339 | input: PullRequestRef, |
| 2338 | return summaryUncached(refOfCacheKey(key)); | 2340 | operation: string, |
| 2339 | }, | 2341 | codec: Schema.Codec<A, string>, |
| 2340 | { | 2342 | read: Effect.Effect<A, PullRequestError>, |
| 2341 | capacity: DETAIL_CACHE_CAPACITY, | 2343 | ) { |
| 2342 | timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), | 2344 | const project = yield* requireProject(input); |
| 2343 | }, | 2345 | const key = [ |
| 2344 | ); | 2346 | operation, |
| 2347 | project.api.kind, | ||
| 2348 | project.host.toLowerCase(), | ||
| 2349 | project.repository.toLowerCase(), | ||
| 2350 | project.project.id, | ||
| 2351 | project.project.workspaceRoot, | ||
| 2352 | String(input.number), | ||
| 2353 | ] | ||
| 2354 | .map(encodeURIComponent) | ||
| 2355 | .join(":"); | ||
| 2356 | const lookup = yield* Effect.cached(read); | ||
| 2357 | const encodedRead = lookup.pipe( | ||
| 2358 | Effect.flatMap((value) => | ||
| 2359 | Schema.encodeEffect(codec)(value).pipe( | ||
| 2360 | Effect.mapError( | ||
| 2361 | (cause) => | ||
| 2362 | new PullRequestOperationError({ | ||
| 2363 | operation: "cache", | ||
| 2364 | detail: "Could not encode PR cache data.", | ||
| 2365 | cause, | ||
| 2366 | }), | ||
| 2367 | ), | ||
| 2368 | ), | ||
| 2369 | ), | ||
| 2370 | ); | ||
| 2371 | const payload = yield* readCache.get(key, encodedRead); | ||
| 2372 | const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option); | ||
| 2373 | return Option.isSome(decoded) ? decoded.value : yield* lookup; | ||
| 2374 | }); | ||
| 2375 | const summaryCodec = Schema.fromJsonString(PullRequestSummary); | ||
| 2376 | const stackCodec = Schema.fromJsonString(Schema.NullOr(PullRequestStack)); | ||
| 2377 | |||
| 2345 | const summary: PullRequestService["Service"]["summary"] = (input, options) => { | 2378 | const summary: PullRequestService["Service"]["summary"] = (input, options) => { |
| 2346 | const key = refCacheKey(input); | 2379 | const key = refCacheKey(input); |
| 2347 | const cached = Cache.get(summaryCache, key); | 2380 | const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input)); |
| 2348 | const held = lastGoodSummary.peek(key); | 2381 | const held = lastGoodSummary.peek(key); |
| 2349 | return held !== undefined && | 2382 | return held !== undefined && |
| 2350 | (options?.recoverTransientFailure !== false || held.state === "merged") | 2383 | (options?.recoverTransientFailure !== false || held.state === "merged") |
| ... | @@ -2356,18 +2389,13 @@ export const make = Effect.gen(function* () { | ... | @@ -2356,18 +2389,13 @@ export const make = Effect.gen(function* () { |
| 2356 | ); | 2389 | ); |
| 2357 | }; | 2390 | }; |
| 2358 | 2391 | ||
| 2359 | const stackCache = yield* Cache.makeWith( | ||
| 2360 | (key: string) => { | ||
| 2361 | const [referenceKey, includeDetails] = JSON.parse(key) as [string, boolean]; | ||
| 2362 | return stackUncached(refOfCacheKey(referenceKey), { includeDetails }); | ||
| 2363 | }, | ||
| 2364 | { | ||
| 2365 | capacity: DETAIL_CACHE_CAPACITY, | ||
| 2366 | timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), | ||
| 2367 | }, | ||
| 2368 | ); | ||
| 2369 | const stack: PullRequestService["Service"]["stack"] = (input, options) => | 2392 | const stack: PullRequestService["Service"]["stack"] = (input, options) => |
| 2370 | Cache.get(stackCache, JSON.stringify([refCacheKey(input), options?.includeDetails !== false])); | 2393 | persistedRead( |
| 2394 | input, | ||
| 2395 | `stack:${options?.includeDetails !== false}`, | ||
| 2396 | stackCodec, | ||
| 2397 | stackUncached(input, options), | ||
| 2398 | ); | ||
| 2371 | 2399 | ||
| 2372 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder | 2400 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| 2373 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. | 2401 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. |
| ... | @@ -2642,7 +2670,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2642,7 +2670,7 @@ export const make = Effect.gen(function* () { |
| 2642 | const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { | 2670 | const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { |
| 2643 | const reference = input.reference; | 2671 | const reference = input.reference; |
| 2644 | if (reference !== undefined) { | 2672 | if (reference !== undefined) { |
| 2645 | return Effect.sync(() => bumpRefEpoch(reference)); | 2673 | return readCache.invalidate.pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(reference)))); |
| 2646 | } | 2674 | } |
| 2647 | return Effect.sync(() => { | 2675 | return Effect.sync(() => { |
| 2648 | listingsEpoch = ++epochCounter; | 2676 | listingsEpoch = ++epochCounter; |
| ... | @@ -2652,7 +2680,9 @@ export const make = Effect.gen(function* () { | ... | @@ -2652,7 +2680,9 @@ export const make = Effect.gen(function* () { |
| 2652 | 2680 | ||
| 2653 | const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => { | 2681 | const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => { |
| 2654 | turnRefreshEpoch = listingsEpoch = ++epochCounter; | 2682 | turnRefreshEpoch = listingsEpoch = ++epochCounter; |
| 2655 | return SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch); | 2683 | return readCache.invalidate.pipe( |
| 2684 | Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch)), | ||
| 2685 | ); | ||
| 2656 | }); | 2686 | }); |
| 2657 | 2687 | ||
| 2658 | // A mutation's own client re-reads right after it, and every other client's next read must | 2688 | // A mutation's own client re-reads right after it, and every other client's next read must |
| ... | @@ -2663,7 +2693,9 @@ export const make = Effect.gen(function* () { | ... | @@ -2663,7 +2693,9 @@ export const make = Effect.gen(function* () { |
| 2663 | method: (input: I) => Effect.Effect<void, PullRequestError>, | 2693 | method: (input: I) => Effect.Effect<void, PullRequestError>, |
| 2664 | ): ((input: I) => Effect.Effect<void, PullRequestError>) => | 2694 | ): ((input: I) => Effect.Effect<void, PullRequestError>) => |
| 2665 | (input) => | 2695 | (input) => |
| 2666 | method(input).pipe( | 2696 | readCache.invalidate.pipe( |
| 2697 | Effect.andThen(method(input)), | ||
| 2698 | Effect.ensuring(readCache.invalidate), | ||
| 2667 | Effect.tap(() => | 2699 | Effect.tap(() => |
| 2668 | Effect.sync(() => { | 2700 | Effect.sync(() => { |
| 2669 | bumpRefEpoch(input); | 2701 | bumpRefEpoch(input); |
| ... | @@ -2674,7 +2706,8 @@ export const make = Effect.gen(function* () { | ... | @@ -2674,7 +2706,8 @@ export const make = Effect.gen(function* () { |
| 2674 | const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( | 2706 | const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( |
| 2675 | "PullRequestService.runActionAndInvalidate", | 2707 | "PullRequestService.runActionAndInvalidate", |
| 2676 | )(function* (input) { | 2708 | )(function* (input) { |
| 2677 | const repository = yield* runAction(input); | 2709 | yield* readCache.invalidate; |
| 2710 | const repository = yield* runAction(input).pipe(Effect.ensuring(readCache.invalidate)); | ||
| 2678 | bumpRefEpoch({ ...input, repository }); | 2711 | bumpRefEpoch({ ...input, repository }); |
| 2679 | listingsEpoch = ++epochCounter; | 2712 | listingsEpoch = ++epochCounter; |
| 2680 | if (input.action === "merge") { | 2713 | if (input.action === "merge") { |
apps/server/src/server.ts+2| ... | @@ -93,6 +93,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | ... | @@ -93,6 +93,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 93 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 93 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 94 | import * as ReviewService from "./review/ReviewService.ts"; | 94 | import * as ReviewService from "./review/ReviewService.ts"; |
| 95 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 95 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 96 | import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; | ||
| 96 | import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; | 97 | import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; |
| 97 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 98 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 98 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | 99 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| ... | @@ -318,6 +319,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay | ... | @@ -318,6 +319,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay |
| 318 | 319 | ||
| 319 | const PullRequestServiceLive = PullRequestService.layer.pipe( | 320 | const PullRequestServiceLive = PullRequestService.layer.pipe( |
| 320 | Layer.provide(PullRequestProviderRegistry.layer), | 321 | Layer.provide(PullRequestProviderRegistry.layer), |
| 322 | Layer.provide(PullRequestReadCache.layer), | ||
| 321 | Layer.provide(SourceControlProviderRegistryLayerLive), | 323 | Layer.provide(SourceControlProviderRegistryLayerLive), |
| 322 | Layer.provide(SourceControlRateLimit.layer), | 324 | Layer.provide(SourceControlRateLimit.layer), |
| 323 | ); | 325 | ); |