mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 01:07:06 +03:00
162 lines
7.3 KiB
TypeScript
162 lines
7.3 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Cause, Deferred, Duration, Effect, Exit, Fiber } from "effect"
|
|
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"
|
|
import { TestClock } from "effect/testing"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { SharedCache } from "@opencode/core/shared-cache"
|
|
import { testEffect } from "./lib/effect"
|
|
|
|
const it = testEffect(AppNodeBuilder.build(SharedCache.node))
|
|
|
|
const request = HttpClientRequest.get("https://models.example/v1")
|
|
const status = (code: number) =>
|
|
HttpClient.filterStatusOk(
|
|
HttpClient.make((request) =>
|
|
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: code }))),
|
|
),
|
|
)
|
|
.execute(request)
|
|
.pipe(Effect.flip)
|
|
|
|
describe("SharedCache", () => {
|
|
it.effect("counts network errors, timeouts and 408, 429 or 5xx as transient, and nothing else", () =>
|
|
Effect.gen(function* () {
|
|
const reason = (value: HttpClientError.HttpClientError["reason"]) =>
|
|
new HttpClientError.HttpClientError({ reason: value })
|
|
expect(SharedCache.transient(reason(new HttpClientError.TransportError({ request })))).toBe(true)
|
|
expect(SharedCache.transient(new Cause.TimeoutError())).toBe(true)
|
|
expect(SharedCache.transient(yield* status(503))).toBe(true)
|
|
expect(SharedCache.transient(yield* status(429))).toBe(true)
|
|
expect(SharedCache.transient(yield* status(401))).toBe(false)
|
|
expect(SharedCache.transient(yield* status(404))).toBe(false)
|
|
expect(SharedCache.transient(reason(new HttpClientError.InvalidUrlError({ request })))).toBe(false)
|
|
expect(SharedCache.transient(new TypeError("fetch failed"))).toBe(false)
|
|
}),
|
|
)
|
|
|
|
it.effect("joins a running load, refreshes after the caller's interval, and forces without overlapping", () =>
|
|
Effect.gen(function* () {
|
|
const cache = yield* SharedCache.Service
|
|
const loads = { a: 0, b: 0 }
|
|
const gate = yield* Deferred.make<void>()
|
|
const get = (key: "a" | "b", options: { interval?: Duration.Input; force?: boolean } = {}) =>
|
|
cache.get({ key, ...options, load: Deferred.await(gate).pipe(Effect.map(() => `${key}${++loads[key]}`)) })
|
|
|
|
const joined = yield* Effect.all([get("a"), get("a", { force: true }), get("b")], {
|
|
concurrency: "unbounded",
|
|
}).pipe(Effect.forkChild({ startImmediately: true }))
|
|
yield* Deferred.succeed(gate, undefined)
|
|
expect(yield* Fiber.join(joined)).toEqual(["a1", "a1", "b1"])
|
|
|
|
yield* TestClock.adjust("5 minutes")
|
|
expect(yield* get("a")).toBe("a1")
|
|
expect(yield* get("a", { interval: "1 minute" })).toBe("a2")
|
|
expect(yield* get("a", { force: true })).toBe("a3")
|
|
yield* TestClock.adjust("10 minutes")
|
|
expect(yield* get("b")).toBe("b2")
|
|
expect(loads).toEqual({ a: 3, b: 2 })
|
|
}),
|
|
)
|
|
|
|
it.effect("retries transient failures, then keeps the last list or the failure until the next interval", () =>
|
|
Effect.gen(function* () {
|
|
const cache = yield* SharedCache.Service
|
|
const remote = { results: [] as unknown[], loads: 0 }
|
|
const get = (force?: boolean) =>
|
|
cache.get({
|
|
key: "models",
|
|
force,
|
|
load: Effect.suspend(() => {
|
|
remote.loads++
|
|
const result = remote.results.shift()
|
|
return typeof result === "string" ? Effect.succeed(result) : Effect.fail(result)
|
|
}),
|
|
})
|
|
const timeout = new Cause.TimeoutError()
|
|
|
|
remote.results = [401]
|
|
expect(yield* Effect.flip(get())).toBe(401)
|
|
yield* TestClock.adjust("9 minutes")
|
|
expect(yield* Effect.flip(get())).toBe(401)
|
|
remote.results = [timeout, timeout, "list"]
|
|
const flaky = yield* get(true).pipe(Effect.forkChild({ startImmediately: true }))
|
|
yield* Effect.yieldNow.pipe(Effect.andThen(TestClock.adjust("1 second")))
|
|
yield* Effect.yieldNow.pipe(Effect.andThen(TestClock.adjust("2 seconds")))
|
|
expect(yield* Fiber.join(flaky)).toBe("list")
|
|
expect(remote.loads).toBe(4)
|
|
|
|
remote.results = [401, timeout, timeout, timeout, timeout]
|
|
expect(yield* get(true)).toBe("list")
|
|
const outage = yield* get(true).pipe(Effect.forkChild({ startImmediately: true }))
|
|
for (const wait of ["1 second", "2 seconds", "4 seconds"] as const)
|
|
yield* Effect.yieldNow.pipe(Effect.andThen(TestClock.adjust(wait)))
|
|
expect(yield* Fiber.join(outage)).toBe("list")
|
|
expect(yield* get()).toBe("list")
|
|
expect(remote.loads).toBe(9)
|
|
}),
|
|
)
|
|
|
|
it.effect("never keeps interrupts or defects, and an interrupted caller leaves the load to the others", () =>
|
|
Effect.gen(function* () {
|
|
const cache = yield* SharedCache.Service
|
|
const gate = yield* Deferred.make<void>()
|
|
const loads = { count: 0 }
|
|
const get = (load: Effect.Effect<number>) =>
|
|
cache.get({
|
|
key: "models",
|
|
load: Effect.suspend(() => {
|
|
loads.count++
|
|
return load
|
|
}),
|
|
})
|
|
|
|
const first = yield* get(Deferred.await(gate).pipe(Effect.as(1))).pipe(
|
|
Effect.forkChild({ startImmediately: true }),
|
|
)
|
|
const second = yield* get(Effect.succeed(2)).pipe(Effect.forkChild({ startImmediately: true }))
|
|
yield* Effect.yieldNow
|
|
yield* Fiber.interrupt(first)
|
|
yield* Deferred.succeed(gate, undefined)
|
|
expect(yield* Fiber.join(second)).toBe(1)
|
|
|
|
yield* TestClock.adjust("10 minutes")
|
|
expect(Exit.hasDies(yield* Effect.exit(get(Effect.die("bug"))))).toBe(true)
|
|
expect(Exit.hasInterrupts(yield* Effect.exit(get(Effect.interrupt)))).toBe(true)
|
|
expect(yield* get(Effect.succeed(3))).toBe(3)
|
|
expect(loads.count).toBe(4)
|
|
}),
|
|
)
|
|
|
|
it.effect("drops entries unread for 24 hours, never one still in use or loading", () =>
|
|
Effect.gen(function* () {
|
|
const cache = yield* SharedCache.Service
|
|
const loads = { idle: 0, used: 0, daily: 0, slow: 0 }
|
|
const once = (key: keyof typeof loads) =>
|
|
Effect.suspend(() => (++loads[key] === 1 ? Effect.succeed(key) : Effect.fail("offline")))
|
|
const gate = yield* Deferred.make<void>()
|
|
|
|
expect(yield* cache.get({ key: "idle", load: once("idle") })).toBe("idle")
|
|
expect(yield* cache.get({ key: "daily", interval: "48 hours", load: once("daily") })).toBe("daily")
|
|
const getSlow = cache
|
|
.get({ key: "slow", load: Deferred.await(gate).pipe(Effect.andThen(once("slow"))) })
|
|
.pipe(Effect.forkChild({ startImmediately: true }))
|
|
const slow = yield* getSlow
|
|
yield* Effect.yieldNow
|
|
// A Location refreshing every 10 minutes keeps its entry, so its list survives later failures.
|
|
for (let elapsed = 0; elapsed <= 25 * 60; elapsed += 10) {
|
|
expect(yield* cache.get({ key: "used", load: once("used") })).toBe("used")
|
|
yield* TestClock.adjust("10 minutes")
|
|
}
|
|
|
|
expect(yield* Effect.flip(cache.get({ key: "idle", load: once("idle") }))).toBe("offline")
|
|
expect(loads.idle).toBe(2)
|
|
yield* TestClock.adjust("23 hours")
|
|
expect(yield* cache.get({ key: "daily", interval: "48 hours", load: once("daily") })).toBe("daily")
|
|
const joined = yield* getSlow
|
|
yield* Deferred.succeed(gate, undefined)
|
|
expect([yield* Fiber.join(slow), yield* Fiber.join(joined)]).toEqual(["slow", "slow"])
|
|
expect(loads.slow).toBe(1)
|
|
}),
|
|
)
|
|
})
|