Repository navigation
Expand file tree
/
Copy pathPullRequestReadCache.test.ts
More file actions
280 lines (269 loc) · 12.1 KB
/
Copy pathPullRequestReadCache.test.ts
File metadata and controls
280 lines (269 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
import * as NodeV8 from "node:v8";
import * as NodeVM from "node:vm";
import { assert, it } from "@effect/vitest";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Layer from "effect/Layer";
import { PullRequestOperationError } from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as KeyValueStore from "effect/persistence/KeyValueStore";
import * as TestClock from "effect/testing/TestClock";
import * as PullRequestReadCache from "./PullRequestReadCache.ts";
NodeV8.setFlagsFromString("--expose-gc");
const collectGarbage = NodeVM.runInNewContext("gc") as () => void;
const cacheLayer = (directory: string) =>
PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory)));
it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PR filesystem cache", (it) => {
it.effect("clears in-flight reads before a new service can reuse them", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" });
const started = yield* Deferred.make<void>();
const release = yield* Deferred.make<void>();
const cache = yield* cacheLayer(directory);
const read = yield* cache
.get(
"summary",
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as("old"),
),
["pr"],
)
.pipe(Effect.forkChild);
yield* Deferred.await(started);
const invalidate = yield* cache
.invalidate("pr")
.pipe(Effect.forkChild({ startImmediately: true }));
// Another PR's invalidation does not wait on this read; this one's does.
yield* cache.invalidate("other-pr");
assert.isUndefined(invalidate.pollUnsafe());
yield* Deferred.succeed(release, undefined);
yield* Fiber.join(read);
yield* Fiber.join(invalidate);
const restarted = yield* cacheLayer(directory);
assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new"), ["pr"]), "new");
}),
);
it.effect("prunes expired entry files the store wrote", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" });
const cache = yield* cacheLayer(directory);
yield* cache.get("summary", Effect.succeed("value"));
const [entry] = yield* fs.readDirectory(directory);
assert.isDefined(entry);
const info = yield* fs.stat(path.join(directory, entry));
yield* TestClock.setTime(
Option.getOrThrow(info.mtime).getTime() + Duration.toMillis(Duration.days(2)),
);
yield* PullRequestReadCache.pruneExpiredEntryFiles(directory);
assert.deepStrictEqual(yield* fs.readDirectory(directory), []);
}),
);
it.effect("invalidates only the changed scope across restarts and coalesces its next reads", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" });
let reads = 0;
const lookup = Effect.sync(() => String(++reads));
const cache = yield* cacheLayer(directory);
yield* cache.get("first", lookup, ["project", "pr-1"]);
yield* cache.get("second", lookup, ["project", "pr-2"]);
yield* cache.get("third", lookup, ["other-project", "pr-3"]);
yield* cache.invalidate("pr-1");
const restarted = yield* cacheLayer(directory);
const answers = yield* Effect.all(
Array.from({ length: 10 }, () => restarted.get("first", lookup, ["project", "pr-1"])),
{ concurrency: 10 },
);
assert.deepStrictEqual(answers, Array(10).fill("4"));
assert.strictEqual(yield* restarted.get("second", lookup, ["project", "pr-2"]), "2");
yield* restarted.invalidate("project");
const again = yield* cacheLayer(directory);
assert.strictEqual(yield* again.get("second", lookup, ["project", "pr-2"]), "5");
assert.strictEqual(yield* again.get("third", lookup, ["other-project", "pr-3"]), "3");
assert.strictEqual(reads, 5);
const files = (yield* fs.readDirectory(directory)).length;
for (let index = 0; index < 3; index++) {
yield* again.invalidate("pr-1");
yield* again.get("first", lookup, ["project", "pr-1"]);
}
assert.strictEqual((yield* fs.readDirectory(directory)).length, files);
}),
);
it.effect("shares a pending refresh without blocking an unrelated cached PR", () =>
Effect.gen(function* () {
const cache = yield* PullRequestReadCache.make;
yield* cache.get("first", Effect.succeed("old"), ["pr-1"]);
yield* cache.get("second", Effect.succeed("warm"), ["pr-2"]);
yield* cache.invalidate("pr-1");
const started = yield* Deferred.make<void>();
const release = yield* Deferred.make<void>();
let reads = 0;
const refresh = cache.get(
"first",
Effect.gen(function* () {
reads++;
yield* Deferred.succeed(started, undefined);
yield* Deferred.await(release);
return "fresh";
}),
["pr-1"],
);
const pending = yield* Effect.all(
Array.from({ length: 10 }, () => refresh),
{
concurrency: 10,
},
).pipe(Effect.forkChild);
yield* Deferred.await(started);
assert.strictEqual(yield* cache.get("second", Effect.die("cache miss"), ["pr-2"]), "warm");
yield* Deferred.succeed(release, undefined);
assert.deepStrictEqual(yield* Fiber.join(pending), Array(10).fill("fresh"));
assert.strictEqual(reads, 1);
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
);
it.effect("does not persist failed GitHub reads", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" });
const cache = yield* cacheLayer(directory);
const error = new PullRequestOperationError({ operation: "summary", detail: "unavailable" });
yield* cache.get("summary", Effect.fail(error)).pipe(Effect.flip);
const restarted = yield* cacheLayer(directory);
assert.strictEqual(yield* restarted.get("summary", Effect.succeed("recovered")), "recovered");
}),
);
it.effect.each(["", "{", '{"pr":{"revision":42}}'])(
"recovers from malformed scope revisions without reusing stale entries: %j",
(corrupt) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" });
const original = yield* cacheLayer(directory);
yield* original.get("summary", Effect.succeed("stale"), ["pr"]);
yield* fs.writeFileString(directory + "/revisions", corrupt);
const recovered = yield* cacheLayer(directory);
let reads = 0;
const lookup = Effect.sync(() => String(++reads));
assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "1");
assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "1");
yield* recovered.invalidate("pr");
assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "2");
const restarted = yield* cacheLayer(directory);
assert.strictEqual(yield* restarted.get("summary", lookup, ["pr"]), "2");
assert.strictEqual(reads, 2);
}),
);
it.effect("resumes caching after a failed scope read", () =>
Effect.gen(function* () {
const backing = yield* KeyValueStore.KeyValueStore;
let fail = true;
let reads = 0;
const cache = yield* PullRequestReadCache.make.pipe(
Effect.provideService(KeyValueStore.KeyValueStore, {
...backing,
get: (key) =>
Effect.suspend(() => {
if (!fail) return backing.get(key);
fail = false;
return Effect.fail(
new KeyValueStore.KeyValueStoreError({ method: "get", message: "unavailable" }),
);
}),
}),
);
const read = cache.get(
"summary",
Effect.sync(() => String(++reads)),
["pr"],
);
assert.strictEqual(yield* read, "1");
assert.strictEqual(yield* read, "2");
assert.strictEqual(yield* read, "2");
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
);
it.effect("does not keep a caller's data alive after its reads and invalidations", () =>
Effect.gen(function* () {
const cache = yield* PullRequestReadCache.make;
const sweeps: Array<WeakRef<object>> = [];
// Like a sync sweep: an Effect.fn made per run that closes over that run's data.
const sweep = Effect.fn("sweep")(function* (index: number) {
const groups = new Map<string, number>(
Array.from({ length: 1_000 }, (_, id) => [`group-${id}`, id]),
);
sweeps.push(new WeakRef(groups));
const read = Effect.fn("read")(function* (pr: string) {
const size = groups.size;
yield* cache.invalidate(pr);
const answer = yield* cache.get(`${pr}-${index}`, Effect.succeed("answer"), [pr]);
return size + answer.length;
});
yield* Effect.forEach(["pr-1", "pr-2"], read, { discard: true });
});
for (let index = 0; index < 5; index++) yield* sweep(index);
// WeakRef targets stay alive until the current job ends, so yield before collecting.
yield* Effect.yieldNow;
collectGarbage();
assert.strictEqual(sweeps.filter((sweepData) => sweepData.deref() !== undefined).length, 0);
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
);
it.effect("cancels abandoned reads without blocking invalidation", () =>
Effect.gen(function* () {
const cache = yield* PullRequestReadCache.make;
const started = yield* Deferred.make<void>();
const read = yield* cache
.get("summary", Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), [
"pr",
])
.pipe(Effect.forkChild);
yield* Deferred.await(started);
yield* Fiber.interrupt(read);
yield* cache.invalidate("pr");
assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh");
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
);
it.effect(
"finishes the in-memory revision update when invalidation is canceled after writing",
() =>
Effect.gen(function* () {
const backing = yield* KeyValueStore.KeyValueStore;
const written = yield* Deferred.make<void>();
const release = yield* Deferred.make<void>();
const cache = yield* PullRequestReadCache.make.pipe(
Effect.provideService(KeyValueStore.KeyValueStore, {
...backing,
set: (key, value) =>
backing
.set(key, value)
.pipe(
Effect.andThen(
key === "revisions"
? Deferred.succeed(written, undefined).pipe(
Effect.andThen(Deferred.await(release)),
)
: Effect.void,
),
),
}),
);
yield* cache.get("summary", Effect.succeed("old"), ["pr"]);
const invalidation = yield* cache.invalidate("pr").pipe(Effect.forkChild);
yield* Deferred.await(written);
const interrupt = yield* Fiber.interrupt(invalidation).pipe(
Effect.forkChild({ startImmediately: true }),
);
yield* Deferred.succeed(release, undefined);
yield* Fiber.join(interrupt);
assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh");
}).pipe(Effect.provide(KeyValueStore.layerMemory)),
);
});