@kairos-es/feed-postgres 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +84 -2
  3. package/dist/cjs/config.js +46 -0
  4. package/dist/cjs/config.js.map +1 -0
  5. package/dist/cjs/ensureFeedSchema.js +39 -0
  6. package/dist/cjs/ensureFeedSchema.js.map +1 -0
  7. package/dist/cjs/feed.js +176 -0
  8. package/dist/cjs/feed.js.map +1 -0
  9. package/dist/cjs/index.js +84 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/internal/context.js +45 -0
  12. package/dist/cjs/internal/context.js.map +1 -0
  13. package/dist/cjs/internal/ddl.js +149 -0
  14. package/dist/cjs/internal/ddl.js.map +1 -0
  15. package/dist/cjs/internal/statements.js +270 -0
  16. package/dist/cjs/internal/statements.js.map +1 -0
  17. package/dist/cjs/lease.js +342 -0
  18. package/dist/cjs/lease.js.map +1 -0
  19. package/dist/cjs/syncStore.js +179 -0
  20. package/dist/cjs/syncStore.js.map +1 -0
  21. package/dist/cjs/withFeed.js +51 -0
  22. package/dist/cjs/withFeed.js.map +1 -0
  23. package/dist/dts-cjs/config.d.ts +37 -0
  24. package/dist/dts-cjs/config.d.ts.map +1 -0
  25. package/dist/dts-cjs/ensureFeedSchema.d.ts +24 -0
  26. package/dist/dts-cjs/ensureFeedSchema.d.ts.map +1 -0
  27. package/dist/dts-cjs/feed.d.ts +81 -0
  28. package/dist/dts-cjs/feed.d.ts.map +1 -0
  29. package/dist/dts-cjs/index.d.ts +50 -0
  30. package/dist/dts-cjs/index.d.ts.map +1 -0
  31. package/dist/dts-cjs/internal/context.d.ts +28 -0
  32. package/dist/dts-cjs/internal/context.d.ts.map +1 -0
  33. package/dist/dts-cjs/internal/ddl.d.ts +99 -0
  34. package/dist/dts-cjs/internal/ddl.d.ts.map +1 -0
  35. package/dist/dts-cjs/internal/statements.d.ts +127 -0
  36. package/dist/dts-cjs/internal/statements.d.ts.map +1 -0
  37. package/dist/dts-cjs/lease.d.ts +138 -0
  38. package/dist/dts-cjs/lease.d.ts.map +1 -0
  39. package/dist/dts-cjs/package.json +3 -0
  40. package/dist/dts-cjs/syncStore.d.ts +57 -0
  41. package/dist/dts-cjs/syncStore.d.ts.map +1 -0
  42. package/dist/dts-cjs/withFeed.d.ts +69 -0
  43. package/dist/dts-cjs/withFeed.d.ts.map +1 -0
  44. package/dist/dts-esm/config.d.ts +37 -0
  45. package/dist/dts-esm/config.d.ts.map +1 -0
  46. package/dist/dts-esm/ensureFeedSchema.d.ts +24 -0
  47. package/dist/dts-esm/ensureFeedSchema.d.ts.map +1 -0
  48. package/dist/dts-esm/feed.d.ts +81 -0
  49. package/dist/dts-esm/feed.d.ts.map +1 -0
  50. package/dist/dts-esm/index.d.ts +50 -0
  51. package/dist/dts-esm/index.d.ts.map +1 -0
  52. package/dist/dts-esm/internal/context.d.ts +28 -0
  53. package/dist/dts-esm/internal/context.d.ts.map +1 -0
  54. package/dist/dts-esm/internal/ddl.d.ts +99 -0
  55. package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
  56. package/dist/dts-esm/internal/statements.d.ts +127 -0
  57. package/dist/dts-esm/internal/statements.d.ts.map +1 -0
  58. package/dist/dts-esm/lease.d.ts +138 -0
  59. package/dist/dts-esm/lease.d.ts.map +1 -0
  60. package/dist/dts-esm/package.json +3 -0
  61. package/dist/dts-esm/syncStore.d.ts +57 -0
  62. package/dist/dts-esm/syncStore.d.ts.map +1 -0
  63. package/dist/dts-esm/withFeed.d.ts +69 -0
  64. package/dist/dts-esm/withFeed.d.ts.map +1 -0
  65. package/dist/esm/config.js +39 -0
  66. package/dist/esm/config.js.map +1 -0
  67. package/dist/esm/ensureFeedSchema.js +31 -0
  68. package/dist/esm/ensureFeedSchema.js.map +1 -0
  69. package/dist/esm/feed.js +166 -0
  70. package/dist/esm/feed.js.map +1 -0
  71. package/dist/esm/index.js +50 -0
  72. package/dist/esm/index.js.map +1 -0
  73. package/dist/esm/internal/context.js +34 -0
  74. package/dist/esm/internal/context.js.map +1 -0
  75. package/dist/esm/internal/ddl.js +136 -0
  76. package/dist/esm/internal/ddl.js.map +1 -0
  77. package/dist/esm/internal/statements.js +262 -0
  78. package/dist/esm/internal/statements.js.map +1 -0
  79. package/dist/esm/lease.js +330 -0
  80. package/dist/esm/lease.js.map +1 -0
  81. package/dist/esm/package.json +4 -0
  82. package/dist/esm/syncStore.js +171 -0
  83. package/dist/esm/syncStore.js.map +1 -0
  84. package/dist/esm/withFeed.js +44 -0
  85. package/dist/esm/withFeed.js.map +1 -0
  86. package/package.json +35 -3
  87. package/src/config.ts +46 -0
  88. package/src/ensureFeedSchema.ts +32 -0
  89. package/src/feed.ts +284 -0
  90. package/src/index.ts +67 -0
  91. package/src/internal/context.ts +44 -0
  92. package/src/internal/ddl.ts +233 -0
  93. package/src/internal/statements.ts +387 -0
  94. package/src/lease.ts +503 -0
  95. package/src/syncStore.ts +340 -0
  96. package/src/withFeed.ts +148 -0
package/src/lease.ts ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * The sync worker's lease on Postgres, in the two kinds `FeedSyncStore`'s doc
3
+ * describes, chosen in config to suit the infrastructure in front of the
4
+ * database.
5
+ *
6
+ * ## Why there are two
7
+ *
8
+ * A worker lease is what keeps a feed's sends to a sink in order: a sink has no
9
+ * transaction, so a rival worker's stale send lands before any compare-and-set can
10
+ * catch it (ADR-0019 §5). The two kinds trade the same two things against each
11
+ * other — what the connection must be, and how long a lapsed worker can go on
12
+ * sending — and neither wins on both.
13
+ *
14
+ * - **`leaseRow`, the default.** The holder and an expiry live on the sink's
15
+ * cursor row, taken with a guarded upsert (only when free, expired, or held by
16
+ * an advisory worker whose session is gone; see below) and renewed by a
17
+ * heartbeat forked into the lease's scope. Every statement is an ordinary short
18
+ * one, and the take one short transaction, so it works through a TRANSACTION
19
+ * POOLER, which drops session state and so rules out a session lock, and it
20
+ * needs no reserved connection. Its cost is a window. `ensureHeld` fails a
21
+ * MARGIN before the stored expiry: once the last successful renewal is
22
+ * `ttl - margin` old by the worker's own `Clock`, that renewal's expiry, which is
23
+ * when a rival may take the row, is at most `margin` away. So a call to the sink
24
+ * that `ensureHeld` let through, and that lands within `margin` of that check,
25
+ * pauses included, lands before any rival can hold the lease, however its
26
+ * renewals have been failing meanwhile, as long as the two workers' clocks
27
+ * agree. A call that outlasts the margin, whether it is slow or its worker
28
+ * pauses (a long GC pause, a network stall), can land after a rival took over:
29
+ * ONE stale call, since the next `ensureHeld` fails; `FeedSyncStore.ts` owns what
30
+ * that call costs. The margin is `ttl / 3` unless configured, and must leave the
31
+ * heartbeat room under the lease (`heartbeat + margin < ttl`), or `ensureHeld`
32
+ * would fail between two renewals that both landed at once. Each renewal is
33
+ * bounded by the heartbeat and the next starts on the next tick, and the age
34
+ * `ensureHeld` judges runs from when a renewal STARTED. So a renewal that lands
35
+ * is at most `heartbeat` late, and the worst age `ensureHeld` meets is
36
+ * `heartbeat` plus that latency with every renewal landing, and `2 * heartbeat`
37
+ * plus it with one lost. A worker therefore goes on sending, without a single
38
+ * failed `ensureHeld`, through renewals that are slow when
39
+ * `2 * heartbeat + margin <= ttl`, and through one that fails or hangs when
40
+ * `3 * heartbeat + margin <= ttl`. The defaults satisfy the second; the
41
+ * construction check enforces only the first's zero-latency form, so a tighter
42
+ * config is accepted and trades those guarantees for a shorter `ttl`.
43
+ * - **`advisoryLock`.** A session `pg_try_advisory_lock` on a connection RESERVED
44
+ * from a client the consumer dedicates to it, keyed by a hash of the prefix, the
45
+ * feed and the sink. The lock goes when the session goes, and `ensureHeld` asks
46
+ * that same session, before every call that changes the sink, whether it still
47
+ * holds the lock; any failure to answer is a lost lease. So a process pause
48
+ * opens no stale window, since the lock survives it. What a check cannot see is
49
+ * the session dying AFTER it: the call it let through is already under way and
50
+ * runs to its end, so it can land as late as its whole duration after the session
51
+ * died, and after a rival has taken the lock and sent. That is still ONE stale
52
+ * call, since the next `ensureHeld` asks a session that is gone, but a sink call
53
+ * can be long — a resync's `publish` most of all. The price is a connection per
54
+ * running worker, and a connection that is genuinely a SESSION: a transaction
55
+ * pooler would hand the lock's session to somebody else between statements. A
56
+ * client this package can recognise as one is refused at construction; see
57
+ * {@link pooledConnectionFault} for what it can and cannot recognise.
58
+ *
59
+ * ## Fencing is the same for both
60
+ *
61
+ * Both kinds record the holder on the cursor row, and the cursor compare-and-set
62
+ * requires it, so a worker whose lease has gone cannot move the cursor whichever
63
+ * kind it held. Mixing the two kinds on one feed and sink fails safe, because
64
+ * neither takes the row from a LIVE holder of the other. A lease-row worker takes
65
+ * over an advisory holder (one with no expiry to lapse) only while it holds that
66
+ * sink's advisory lock for its own transaction, which a live advisory session
67
+ * refuses; so a crashed advisory worker, whose scope never closed to clear the row,
68
+ * does not leave the sink unleasable by every lease-row worker after it. An
69
+ * advisory worker that wins the lock but finds a lease row unexpired on the row lets
70
+ * the lock go and fails `LeaseHeld`. Whichever took the row first keeps it while it
71
+ * lives. The advisory worker likewise takes over an ADVISORY holder recorded on the
72
+ * row, because holding the lock proves that session is gone; it records itself
73
+ * from the session that holds the lock, and only while it still does, so a session
74
+ * that died after its try-lock cannot overwrite the successor that took the lock
75
+ * from it.
76
+ */
77
+ import {
78
+ type FeedLease,
79
+ type FeedSinkId,
80
+ type FeedUnreadable,
81
+ type FeedWriteFailed,
82
+ LeaseHeld,
83
+ LeaseHolder,
84
+ LeaseLost,
85
+ } from '@kairos-es/feed'
86
+ import {
87
+ Clock,
88
+ Duration,
89
+ Effect,
90
+ Option,
91
+ Random,
92
+ Redacted,
93
+ Ref,
94
+ type Scope,
95
+ } from 'effect'
96
+ import type { SqlClient } from 'effect/sql'
97
+ import { type FeedContext, unreadable, writeFailed } from './internal/context'
98
+
99
+ /** The lease kind, as config carries it. See the module doc. */
100
+ export type FeedLeaseConfig =
101
+ | {
102
+ readonly _tag: 'LeaseRow'
103
+ /** How long a lease lasts without a renewal. */
104
+ readonly ttl: Duration.Input
105
+ /** How often the holder renews it; shorter than `ttl`. */
106
+ readonly heartbeat: Duration.Input
107
+ /**
108
+ * How long before the stored expiry `ensureHeld` starts failing: a sink call
109
+ * that takes less than this cannot land after a rival took the lease. See
110
+ * the module doc. `ttl / 3` when absent; `heartbeat + margin` must be under
111
+ * `ttl`.
112
+ */
113
+ readonly margin?: Duration.Input
114
+ }
115
+ | {
116
+ readonly _tag: 'AdvisoryLock'
117
+ /**
118
+ * A client used for nothing but leases, each lease reserving one connection
119
+ * from it for its lifetime. Its lifecycle is the consumer's, exactly as the
120
+ * main client's is.
121
+ */
122
+ readonly connection: SqlClient.SqlClient
123
+ }
124
+
125
+ /** The default lease length. */
126
+ export const DEFAULT_LEASE_TTL: Duration.Duration = Duration.seconds(30)
127
+
128
+ /**
129
+ * The default renewal interval: a fifth of the lease. With the default margin, a
130
+ * third, `3 * heartbeat + margin` is under the `ttl`, so a worker goes on sending,
131
+ * with no failed `ensureHeld`, through one renewal that fails or hangs. However
132
+ * many fail, it stops sending a margin before its lease could lapse. The module
133
+ * doc has the arithmetic.
134
+ */
135
+ export const DEFAULT_LEASE_HEARTBEAT: Duration.Duration = Duration.seconds(6)
136
+
137
+ /** A lease row, the default; see the module doc. */
138
+ export const leaseRow = (
139
+ options: {
140
+ readonly ttl?: Duration.Input
141
+ readonly heartbeat?: Duration.Input
142
+ readonly margin?: Duration.Input
143
+ } = {},
144
+ ): FeedLeaseConfig => ({
145
+ _tag: 'LeaseRow',
146
+ ttl: options.ttl ?? DEFAULT_LEASE_TTL,
147
+ heartbeat: options.heartbeat ?? DEFAULT_LEASE_HEARTBEAT,
148
+ ...(options.margin === undefined ? {} : { margin: options.margin }),
149
+ })
150
+
151
+ /** A session advisory lock on a dedicated connection; see the module doc. */
152
+ export const advisoryLock = (options: {
153
+ readonly connection: SqlClient.SqlClient
154
+ }): FeedLeaseConfig => ({
155
+ _tag: 'AdvisoryLock',
156
+ connection: options.connection,
157
+ })
158
+
159
+ /**
160
+ * Why `client` cannot carry a session lock, when this package can tell, or
161
+ * `undefined`.
162
+ *
163
+ * What it can tell is limited to what a client says about where it connects,
164
+ * which `@effect/sql-pg`'s client exposes as `config` and a generic `SqlClient`
165
+ * does not. It recognises the two pooler conventions that show in an address: a
166
+ * Neon pooled endpoint (a host containing `-pooler`), and PgBouncer's default
167
+ * port, 6432, or a `pgbouncer=true` URL parameter. A pooler on any other address,
168
+ * or behind a client that exposes nothing, passes; that is a wiring the consumer
169
+ * owns. The URL is read only to judge it and is never repeated in the message.
170
+ */
171
+ export const pooledConnectionFault = (
172
+ client: SqlClient.SqlClient,
173
+ ): string | undefined => {
174
+ const config = (client as unknown as { readonly config?: unknown }).config
175
+ if (config === null || typeof config !== 'object') {
176
+ return undefined
177
+ }
178
+ const { host, port, url } = config as {
179
+ readonly host?: unknown
180
+ readonly port?: unknown
181
+ readonly url?: unknown
182
+ }
183
+ const hosts: Array<string> = typeof host === 'string' ? [host] : []
184
+ const ports: Array<number> = typeof port === 'number' ? [port] : []
185
+ let pgbouncer = false
186
+ const rawUrl = Redacted.isRedacted(url)
187
+ ? Redacted.value(url)
188
+ : typeof url === 'string'
189
+ ? url
190
+ : undefined
191
+ if (typeof rawUrl === 'string') {
192
+ try {
193
+ const parsed = new URL(rawUrl)
194
+ hosts.push(parsed.hostname)
195
+ if (parsed.port !== '') {
196
+ ports.push(Number(parsed.port))
197
+ }
198
+ pgbouncer = parsed.searchParams.get('pgbouncer') === 'true'
199
+ } catch {
200
+ // Not a URL this check can read; the driver will say so itself.
201
+ }
202
+ }
203
+ const signs = [
204
+ ...(hosts.some((h) => h.includes('-pooler'))
205
+ ? ['a "-pooler" host, which is a pooled endpoint']
206
+ : []),
207
+ ...(ports.includes(6432) ? ["port 6432, PgBouncer's default"] : []),
208
+ ...(pgbouncer ? ['a pgbouncer=true parameter'] : []),
209
+ ]
210
+ return signs.length === 0
211
+ ? undefined
212
+ : `the advisoryLock connection looks like a transaction pooler (${signs.join('; ')}), which hands a session to other clients between statements and so cannot hold a session lock; give it a direct connection, or use leaseRow`
213
+ }
214
+
215
+ /** How a sync store takes a lease. Package-internal. */
216
+ export type AcquireLease = (
217
+ id: FeedSinkId,
218
+ ) => Effect.Effect<
219
+ FeedLease,
220
+ LeaseHeld | FeedUnreadable | FeedWriteFailed,
221
+ Scope.Scope
222
+ >
223
+
224
+ /** A positive, finite duration, or a construction-time throw naming it. */
225
+ const positive = (label: string, input: Duration.Input): Duration.Duration => {
226
+ const decoded = Duration.fromInputUnsafe(input)
227
+ if (!Duration.isFinite(decoded) || Duration.toMillis(decoded) <= 0) {
228
+ throw new Error(
229
+ `feed-postgres: ${label} must be a positive, finite duration`,
230
+ )
231
+ }
232
+ return decoded
233
+ }
234
+
235
+ /** A fresh holder id, from `Random`, never the host. */
236
+ const newHolder = (kind: string): Effect.Effect<LeaseHolder> =>
237
+ Effect.gen(function* () {
238
+ const high = yield* Random.nextIntBetween(0, 2 ** 31)
239
+ const low = yield* Random.nextIntBetween(0, 2 ** 31)
240
+ return LeaseHolder.make(
241
+ `pg-${kind}:${high.toString(16)}${low.toString(16).padStart(8, '0')}`,
242
+ )
243
+ })
244
+
245
+ /**
246
+ * The advisory lock's name for one feed and sink, hashed server-side into its
247
+ * key. Package-internal, and the one home of the name: `removeSink` tries the
248
+ * same lock to tell whether an advisory worker is live, so the two must agree.
249
+ *
250
+ * @internal
251
+ */
252
+ export const advisoryLockName = (
253
+ context: FeedContext,
254
+ id: FeedSinkId,
255
+ ): string =>
256
+ JSON.stringify([
257
+ 'kairos-es/feed',
258
+ context.names.schema,
259
+ context.names.tablePrefix,
260
+ id.feed,
261
+ id.sink,
262
+ ])
263
+
264
+ type LeaseRowConfig = Extract<FeedLeaseConfig, { readonly _tag: 'LeaseRow' }>
265
+ type AdvisoryLockConfig = Extract<
266
+ FeedLeaseConfig,
267
+ { readonly _tag: 'AdvisoryLock' }
268
+ >
269
+
270
+ /**
271
+ * The lease-row kind; see the module doc. Throws on a heartbeat not under its ttl,
272
+ * or a margin that leaves the heartbeat no room under it.
273
+ */
274
+ const leaseRowAcquirer = (
275
+ config: LeaseRowConfig,
276
+ sql: SqlClient.SqlClient,
277
+ context: FeedContext,
278
+ ): AcquireLease => {
279
+ const { statements } = context
280
+ const ttl = Duration.toMillis(positive('lease ttl', config.ttl))
281
+ const heartbeat = positive('lease heartbeat', config.heartbeat)
282
+ if (Duration.toMillis(heartbeat) >= ttl) {
283
+ throw new Error(
284
+ 'feed-postgres: the lease heartbeat must be shorter than its ttl, or the lease lapses between renewals',
285
+ )
286
+ }
287
+ const margin =
288
+ config.margin === undefined
289
+ ? ttl / 3
290
+ : Duration.toMillis(positive('lease margin', config.margin))
291
+ if (Duration.toMillis(heartbeat) + margin >= ttl) {
292
+ throw new Error(
293
+ 'feed-postgres: the lease heartbeat plus its margin must be shorter than its ttl, or ensureHeld fails between two renewals that both landed',
294
+ )
295
+ }
296
+ // `ensureHeld` fails once the last renewal is this old: its stored expiry is
297
+ // then at most `margin` away.
298
+ const usable = ttl - margin
299
+ return (id) =>
300
+ Effect.gen(function* () {
301
+ const holder = yield* newHolder('row')
302
+ const now = yield* Clock.currentTimeMillis
303
+ const taken = yield* sql
304
+ .withTransaction(
305
+ Effect.gen(function* () {
306
+ // Held to this transaction's end, so an ADVISORY holder on the row can
307
+ // be taken over only while no advisory session holds its lock, and no
308
+ // advisory worker can take the lock before this commits.
309
+ const lock = yield* sql.unsafe<{ readonly taken: boolean }>(
310
+ statements.tryAdvisoryXactLock,
311
+ [advisoryLockName(context, id)],
312
+ )
313
+ return yield* sql.unsafe(statements.takeLeaseRow, [
314
+ id.feed,
315
+ id.sink,
316
+ holder,
317
+ String(now + ttl),
318
+ String(now),
319
+ lock[0]?.taken === true,
320
+ ])
321
+ }),
322
+ )
323
+ .pipe(Effect.mapError(writeFailed('taking the feed lease')))
324
+ if (taken.length === 0) {
325
+ return yield* new LeaseHeld(id)
326
+ }
327
+ const state = yield* Ref.make({ renewedAt: now, lost: false })
328
+ yield* Effect.addFinalizer(() =>
329
+ Effect.ignore(
330
+ sql.unsafe(statements.releaseLease, [id.feed, id.sink, holder]),
331
+ ),
332
+ )
333
+ const renew = Effect.gen(function* () {
334
+ const at = yield* Clock.currentTimeMillis
335
+ const renewed = yield* sql.unsafe(statements.renewLeaseRow, [
336
+ id.feed,
337
+ id.sink,
338
+ holder,
339
+ String(at + ttl),
340
+ ])
341
+ if (renewed.length === 0) {
342
+ yield* Ref.update(state, (s) => ({ ...s, lost: true }))
343
+ } else {
344
+ yield* Ref.update(state, (s) => ({ ...s, renewedAt: at }))
345
+ }
346
+ }).pipe(
347
+ // Bounded by the heartbeat, so a renewal that hangs is abandoned in time
348
+ // for the next tick rather than holding every later one behind it. An
349
+ // abandoned renewal whose cancel comes too late can still land: it touches
350
+ // only this holder's own row, never moves its expiry back
351
+ // (`renewLeaseRow`), and leaves `renewedAt` at the last renewal this fibre
352
+ // saw land, so `ensureHeld` errs early, never late.
353
+ Effect.timeoutOption(heartbeat),
354
+ Effect.flatMap((done) =>
355
+ Option.isSome(done)
356
+ ? Effect.void
357
+ : Effect.logWarning('feed lease renewal timed out', {
358
+ feed: id.feed,
359
+ sink: id.sink,
360
+ }),
361
+ ),
362
+ // A renewal that could not be made is not yet a lost lease: the age
363
+ // check in `ensureHeld` decides that, a margin before the lease lapses.
364
+ Effect.catchTag('SqlError', (cause) =>
365
+ Effect.logWarning('feed lease renewal failed', {
366
+ feed: id.feed,
367
+ sink: id.sink,
368
+ reason: cause.message,
369
+ }),
370
+ ),
371
+ )
372
+ // On a fixed cadence from the take: a renewal never outlasts the interval,
373
+ // so each one starts on its own tick whatever became of the one before. A
374
+ // worker that falls behind (a pause) renews once at once, and does not
375
+ // replay the ticks it missed.
376
+ const interval = Duration.toMillis(heartbeat)
377
+ yield* Effect.forkScoped(
378
+ Effect.gen(function* () {
379
+ let next = now + interval
380
+ while (true) {
381
+ const at = yield* Clock.currentTimeMillis
382
+ if (next > at) {
383
+ yield* Effect.sleep(Duration.millis(next - at))
384
+ }
385
+ yield* renew
386
+ next = Math.max(next + interval, yield* Clock.currentTimeMillis)
387
+ }
388
+ }),
389
+ )
390
+ const lease: FeedLease = {
391
+ holder,
392
+ ensureHeld: Effect.gen(function* () {
393
+ const s = yield* Ref.get(state)
394
+ const at = yield* Clock.currentTimeMillis
395
+ if (s.lost || at - s.renewedAt >= usable) {
396
+ return yield* new LeaseLost(id)
397
+ }
398
+ }),
399
+ }
400
+ return lease
401
+ })
402
+ }
403
+
404
+ /** The advisory-lock kind; see the module doc. Throws on a recognisably pooled connection. */
405
+ const advisoryLockAcquirer = (
406
+ config: AdvisoryLockConfig,
407
+ sql: SqlClient.SqlClient,
408
+ context: FeedContext,
409
+ ): AcquireLease => {
410
+ const fault = pooledConnectionFault(config.connection)
411
+ if (fault !== undefined) {
412
+ throw new Error(`feed-postgres: ${fault}`)
413
+ }
414
+ const { connection } = config
415
+ const { statements } = context
416
+ return (id) =>
417
+ Effect.gen(function* () {
418
+ const lockName = advisoryLockName(context, id)
419
+ const session = yield* connection.reserve.pipe(
420
+ Effect.mapError(unreadable('reserving the feed lease connection')),
421
+ )
422
+ const taken: ReadonlyArray<{ readonly taken?: unknown }> = yield* session
423
+ .execute(statements.tryAdvisoryLock, [lockName], undefined)
424
+ .pipe(Effect.mapError(unreadable('taking the feed advisory lock')))
425
+ if (taken[0]?.taken !== true) {
426
+ return yield* new LeaseHeld(id)
427
+ }
428
+ yield* Effect.addFinalizer(() =>
429
+ Effect.ignore(
430
+ session.execute(statements.advisoryUnlock, [lockName], undefined),
431
+ ),
432
+ )
433
+ const holder = yield* newHolder('advisory')
434
+ const now = yield* Clock.currentTimeMillis
435
+ // On the lock's own session, and conditional on it still holding the lock:
436
+ // a session that died since the try-lock cannot run it, and one that lost
437
+ // the lock lands nothing, so neither overwrites a successor's claim.
438
+ const claimed: ReadonlyArray<unknown> = yield* session
439
+ .execute(
440
+ statements.claimAdvisoryHolder,
441
+ [id.feed, id.sink, holder, String(now), lockName],
442
+ undefined,
443
+ )
444
+ .pipe(Effect.mapError(writeFailed('recording the feed lease holder')))
445
+ if (claimed.length === 0) {
446
+ // A live lease row holds the sink, or this session no longer holds the
447
+ // lock, so this worker must not send. The lock
448
+ // guards nothing now, so it is let go at once rather than when the
449
+ // caller's scope closes, and another advisory worker may try the moment
450
+ // that lease row lapses. The finalizer's second unlock then finds nothing
451
+ // held, which is harmless.
452
+ yield* Effect.ignore(
453
+ session.execute(statements.advisoryUnlock, [lockName], undefined),
454
+ )
455
+ return yield* new LeaseHeld(id)
456
+ }
457
+ yield* Effect.addFinalizer(() =>
458
+ Effect.ignore(
459
+ sql.unsafe(statements.releaseLease, [id.feed, id.sink, holder]),
460
+ ),
461
+ )
462
+ const held: Effect.Effect<boolean> = session
463
+ .execute(statements.holdsAdvisoryLock, [lockName], undefined)
464
+ .pipe(
465
+ Effect.map(
466
+ (rows: ReadonlyArray<{ readonly held?: unknown }>) =>
467
+ rows[0]?.held === true,
468
+ ),
469
+ Effect.catchTag('SqlError', () => Effect.succeed(false)),
470
+ )
471
+ const lease: FeedLease = {
472
+ holder,
473
+ // Asked of the session that holds the lock, so a dead session cannot
474
+ // answer, and an unanswered question is a lost lease.
475
+ ensureHeld: Effect.flatMap(held, (yes) =>
476
+ yes ? Effect.void : Effect.fail(new LeaseLost(id)),
477
+ ),
478
+ }
479
+ return lease
480
+ })
481
+ }
482
+
483
+ /**
484
+ * Build the acquirer for `config`, checking it first: a `heartbeat` that is not
485
+ * shorter than its `ttl`, a `margin` that leaves the heartbeat no room under the
486
+ * `ttl`, or an `advisoryLock` connection recognisably pooled,
487
+ * throws here, which the store's factory turns into a defect at construction.
488
+ * The one place the lease kind is dispatched on.
489
+ *
490
+ * @internal
491
+ */
492
+ export const makeAcquireLease = (
493
+ config: FeedLeaseConfig,
494
+ sql: SqlClient.SqlClient,
495
+ context: FeedContext,
496
+ ): AcquireLease => {
497
+ switch (config._tag) {
498
+ case 'LeaseRow':
499
+ return leaseRowAcquirer(config, sql, context)
500
+ case 'AdvisoryLock':
501
+ return advisoryLockAcquirer(config, sql, context)
502
+ }
503
+ }