1import { assert, describe, it } from "@effect/vitest"
2import { Effect, Layer } from "effect"
3import { type Backend, make } from "./harness.ts"
4
5describe("cluster lock-storage integration", () => {
6 for (const backend of ["pg", "mysql"] satisfies ReadonlyArray<Backend>) {
7 it.live(
8 `${backend}: rebuilds a blackholed reserved lock connection and rejoins without split brain`,
9 () =>
10 Effect.gen(function*() {
11 const cluster = yield* make({ backend, entities: Layer.empty, config: { shardsPerGroup: 4 } })
12 const [faulted] = yield* cluster.start(1)
13 yield* cluster.waitForStableAssignments()
14 const faultedAddress = `${faulted.address.host}:${faulted.address.port}`
15
16 yield* cluster.faultLock(faulted, "blackhole")
17 const [peer] = yield* cluster.start(1)
18 const peerAddress = `${peer.address.host}:${peer.address.port}`
19 yield* cluster.waitUntil(
20 "The peer did not take over shards after the lock connection was blackholed",
21 Effect.gen(function*() {
22 yield* cluster.faultLock(faulted, "blackhole")
23 const assignments = Object.values(cluster.assignmentMap())
24 assert(assignments.every((owners) => owners.length <= 1), "A shard had multiple owners")
25 return assignments.every((owners) => !owners.includes(faultedAddress)) &&
26 assignments.some((owners) => owners.includes(peerAddress))
27 })
28 )
29
30 yield* cluster.faultLock(faulted, "clear")
31 const recovered = yield* cluster.waitForStableAssignments()
32 assert(
33 Object.values(recovered).some((owners) => owners.includes(faultedAddress)),
34 "The recovered runner did not rejoin the assignment ring"
35 )
36 })
37 )
38
39 it.live(`${backend}: does not reacquire a shard before forced release completes`, () =>
40 Effect.gen(function*() {
41 const cluster = yield* make({ backend, entities: Layer.empty, config: { shardsPerGroup: 4 } })
42 const [faulted] = yield* cluster.start(1)
43 yield* cluster.waitForStableAssignments()
44 const faultedAddress = `${faulted.address.host}:${faulted.address.port}`
45
46 yield* cluster.faultLock(faulted, "hangRelease")
47 yield* cluster.waitUntil(
48 "The faulted runner did not drop its shards",
49 Effect.sync(() => {
50 const assignments = Object.values(cluster.assignmentMap())
51 assert(assignments.every((owners) => owners.length <= 1), "A shard had multiple owners")
52 return assignments.every((owners) => !owners.includes(faultedAddress))
53 })
54 )
55
56 const [peer] = yield* cluster.start(1)
57 const peerAddress = `${peer.address.host}:${peer.address.port}`
58 yield* cluster.waitUntil(
59 "The peer did not make progress while the forced release was pending",
60 Effect.sync(() => {
61 const assignments = Object.values(cluster.assignmentMap())
62 assert(assignments.every((owners) => owners.length <= 1), "A shard had multiple owners")
63 return assignments.some((owners) => owners.includes(peerAddress))
64 })
65 )
66
67 yield* cluster.faultLock(faulted, "clear")
68 let previous = ""
69 let stablePolls = 0
70 yield* cluster.waitUntil(
71 "The cluster did not reach a terminal assignment after the forced release",
72 Effect.sync(() => {
73 const assignments = cluster.assignmentMap()
74 const owners = Object.values(assignments)
75 assert(owners.every((shardOwners) => shardOwners.length <= 1), "A shard had multiple owners")
76 const complete = owners.every((shardOwners) => shardOwners.length === 1) &&
77 owners.some((shardOwners) => shardOwners.includes(faultedAddress)) &&
78 owners.some((shardOwners) => shardOwners.includes(peerAddress))
79 const encoded = complete ? JSON.stringify(assignments) : ""
80 stablePolls = encoded !== "" && encoded === previous ? stablePolls + 1 : 0
81 previous = encoded
82 return stablePolls >= 3
83 })
84 )
85 }))
86
87 it.live(`${backend}: keeps peers progressing when a lock query is permanently stuck`, () =>
88 Effect.gen(function*() {
89 const cluster = yield* make({ backend, entities: Layer.empty, config: { shardsPerGroup: 4 } })
90 const [faulted] = yield* cluster.start(1)
91 yield* cluster.waitForStableAssignments()
92 const faultedAddress = `${faulted.address.host}:${faulted.address.port}`
93
94 yield* cluster.faultLock(faulted, "stuck")
95 yield* cluster.waitUntil(
96 "The permanently stuck runner did not drop its shards",
97 Effect.sync(() => Object.values(cluster.assignmentMap()).every((owners) => !owners.includes(faultedAddress)))
98 )
99
100 const [peer] = yield* cluster.start(1)
101 const peerAddress = `${peer.address.host}:${peer.address.port}`
102 yield* cluster.waitUntil(
103 "The peer did not acquire shards while the other lock query remained stuck",
104 Effect.sync(() => {
105 const owners = Object.values(cluster.assignmentMap())
106 assert(owners.every((shardOwners) => !shardOwners.includes(faultedAddress)))
107 return owners.some((shardOwners) => shardOwners.includes(peerAddress))
108 })
109 )
110
111 yield* cluster.faultLock(faulted, "clear")
112 yield* cluster.waitForStableAssignments()
113 }))
114
115 it.live(`${backend}: converges after repeated lock-connection rebuilds and a hung release`, () =>
116 Effect.gen(function*() {
117 const cluster = yield* make({ backend, entities: Layer.empty, config: { shardsPerGroup: 4 } })
118 const [faulted] = yield* cluster.start(1)
119 yield* cluster.waitForStableAssignments()
120 const faultedAddress = `${faulted.address.host}:${faulted.address.port}`
121
122 yield* cluster.faultLock(faulted, "fail")
123 let healthyPolls = 0
124 yield* cluster.waitUntil(
125 "The runner lost shards while rebuilding failed lock connections",
126 Effect.gen(function*() {
127 yield* cluster.faultLock(faulted, "fail")
128 const assignments = Object.values(cluster.assignmentMap())
129 assert(
130 assignments.every((owners) => owners.length === 1 && owners.includes(faultedAddress)),
131 "The runner lost a shard during lock-connection rebuild"
132 )
133 healthyPolls++
134 return healthyPolls >= 10
135 })
136 )
137
138 yield* cluster.faultLock(faulted, "hangRelease")
139 yield* cluster.waitUntil(
140 "The runner did not drop its shards while the previous connection release hung",
141 Effect.sync(() => Object.values(cluster.assignmentMap()).every((owners) => !owners.includes(faultedAddress)))
142 )
143
144 const [peer] = yield* cluster.start(1)
145 const peerAddress = `${peer.address.host}:${peer.address.port}`
146 yield* cluster.waitUntil(
147 "The peer did not acquire shards during the repeated rebuild",
148 Effect.sync(() => Object.values(cluster.assignmentMap()).some((owners) => owners.includes(peerAddress)))
149 )
150
151 yield* cluster.faultLock(faulted, "clear")
152 yield* cluster.waitForStableAssignments()
153 }))
154 }
155})