@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.
- package/LICENSE +28 -0
- package/README.md +84 -2
- package/dist/cjs/config.js +46 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/ensureFeedSchema.js +39 -0
- package/dist/cjs/ensureFeedSchema.js.map +1 -0
- package/dist/cjs/feed.js +176 -0
- package/dist/cjs/feed.js.map +1 -0
- package/dist/cjs/index.js +84 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/context.js +45 -0
- package/dist/cjs/internal/context.js.map +1 -0
- package/dist/cjs/internal/ddl.js +149 -0
- package/dist/cjs/internal/ddl.js.map +1 -0
- package/dist/cjs/internal/statements.js +270 -0
- package/dist/cjs/internal/statements.js.map +1 -0
- package/dist/cjs/lease.js +342 -0
- package/dist/cjs/lease.js.map +1 -0
- package/dist/cjs/syncStore.js +179 -0
- package/dist/cjs/syncStore.js.map +1 -0
- package/dist/cjs/withFeed.js +51 -0
- package/dist/cjs/withFeed.js.map +1 -0
- package/dist/dts-cjs/config.d.ts +37 -0
- package/dist/dts-cjs/config.d.ts.map +1 -0
- package/dist/dts-cjs/ensureFeedSchema.d.ts +24 -0
- package/dist/dts-cjs/ensureFeedSchema.d.ts.map +1 -0
- package/dist/dts-cjs/feed.d.ts +81 -0
- package/dist/dts-cjs/feed.d.ts.map +1 -0
- package/dist/dts-cjs/index.d.ts +50 -0
- package/dist/dts-cjs/index.d.ts.map +1 -0
- package/dist/dts-cjs/internal/context.d.ts +28 -0
- package/dist/dts-cjs/internal/context.d.ts.map +1 -0
- package/dist/dts-cjs/internal/ddl.d.ts +99 -0
- package/dist/dts-cjs/internal/ddl.d.ts.map +1 -0
- package/dist/dts-cjs/internal/statements.d.ts +127 -0
- package/dist/dts-cjs/internal/statements.d.ts.map +1 -0
- package/dist/dts-cjs/lease.d.ts +138 -0
- package/dist/dts-cjs/lease.d.ts.map +1 -0
- package/dist/dts-cjs/package.json +3 -0
- package/dist/dts-cjs/syncStore.d.ts +57 -0
- package/dist/dts-cjs/syncStore.d.ts.map +1 -0
- package/dist/dts-cjs/withFeed.d.ts +69 -0
- package/dist/dts-cjs/withFeed.d.ts.map +1 -0
- package/dist/dts-esm/config.d.ts +37 -0
- package/dist/dts-esm/config.d.ts.map +1 -0
- package/dist/dts-esm/ensureFeedSchema.d.ts +24 -0
- package/dist/dts-esm/ensureFeedSchema.d.ts.map +1 -0
- package/dist/dts-esm/feed.d.ts +81 -0
- package/dist/dts-esm/feed.d.ts.map +1 -0
- package/dist/dts-esm/index.d.ts +50 -0
- package/dist/dts-esm/index.d.ts.map +1 -0
- package/dist/dts-esm/internal/context.d.ts +28 -0
- package/dist/dts-esm/internal/context.d.ts.map +1 -0
- package/dist/dts-esm/internal/ddl.d.ts +99 -0
- package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
- package/dist/dts-esm/internal/statements.d.ts +127 -0
- package/dist/dts-esm/internal/statements.d.ts.map +1 -0
- package/dist/dts-esm/lease.d.ts +138 -0
- package/dist/dts-esm/lease.d.ts.map +1 -0
- package/dist/dts-esm/package.json +3 -0
- package/dist/dts-esm/syncStore.d.ts +57 -0
- package/dist/dts-esm/syncStore.d.ts.map +1 -0
- package/dist/dts-esm/withFeed.d.ts +69 -0
- package/dist/dts-esm/withFeed.d.ts.map +1 -0
- package/dist/esm/config.js +39 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/ensureFeedSchema.js +31 -0
- package/dist/esm/ensureFeedSchema.js.map +1 -0
- package/dist/esm/feed.js +166 -0
- package/dist/esm/feed.js.map +1 -0
- package/dist/esm/index.js +50 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/context.js +34 -0
- package/dist/esm/internal/context.js.map +1 -0
- package/dist/esm/internal/ddl.js +136 -0
- package/dist/esm/internal/ddl.js.map +1 -0
- package/dist/esm/internal/statements.js +262 -0
- package/dist/esm/internal/statements.js.map +1 -0
- package/dist/esm/lease.js +330 -0
- package/dist/esm/lease.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/syncStore.js +171 -0
- package/dist/esm/syncStore.js.map +1 -0
- package/dist/esm/withFeed.js +44 -0
- package/dist/esm/withFeed.js.map +1 -0
- package/package.json +35 -3
- package/src/config.ts +46 -0
- package/src/ensureFeedSchema.ts +32 -0
- package/src/feed.ts +284 -0
- package/src/index.ts +67 -0
- package/src/internal/context.ts +44 -0
- package/src/internal/ddl.ts +233 -0
- package/src/internal/statements.ts +387 -0
- package/src/lease.ts +503 -0
- package/src/syncStore.ts +340 -0
- 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
|
+
}
|