slotlock 0.6.1 → 0.6.3
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/CHANGELOG.md +34 -0
- package/README.md +13 -6
- package/SECURITY.md +15 -4
- package/agent-server.js +1 -5
- package/agent-store-backend.d.ts +5 -6
- package/agent-store-backend.js +41 -67
- package/api-keys.js +1 -1
- package/application-api.d.ts +3 -3
- package/application-api.js +16 -24
- package/assistant.d.ts +18 -27
- package/assistant.js +36 -53
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +17 -2
- package/config.js +44 -6
- package/dashboard.js +14 -1
- package/ddl.d.ts +20 -27
- package/ddl.js +166 -108
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +23 -31
- package/event-feed.js +30 -45
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/interop.js +1 -4
- package/maintenance.d.ts +14 -10
- package/maintenance.js +43 -26
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.js +0 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/rate-limit.d.ts +6 -1
- package/rate-limit.js +24 -8
- package/rest-api.d.ts +13 -14
- package/rest-api.js +11 -12
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/self-host.d.ts +16 -2
- package/self-host.js +42 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +109 -161
- package/store.js +253 -371
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/timezone.d.ts +25 -33
- package/timezone.js +37 -52
- package/types.d.ts +35 -56
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/store.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// DB mechanics for the Slotlock core
|
|
2
|
-
// Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
|
|
3
|
-
// reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
|
|
1
|
+
// DB mechanics for the Slotlock core: explicit `postgres` client in, structured results out.
|
|
4
2
|
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
|
|
5
3
|
import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
6
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
@@ -10,44 +8,36 @@ import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
|
10
8
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
11
9
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
|
|
12
10
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
13
|
-
/** Postgres exclusion_violation — the EXCLUDE constraint fired. */
|
|
14
11
|
const EXCLUSION_VIOLATION = '23P01';
|
|
15
12
|
/**
|
|
16
|
-
* Concurrent inserts
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* wins cleanly or loses with a proper 23P01 — both structured. Never surfaced raw.
|
|
13
|
+
* Concurrent inserts on the same EXCLUDE range can deadlock inside the constraint check itself
|
|
14
|
+
* (intrinsic to Postgres EXCLUDE). The victim is fully rolled back, so one retry either wins or
|
|
15
|
+
* loses with a structured 23P01.
|
|
20
16
|
*/
|
|
21
17
|
const DEADLOCK_DETECTED = '40P01';
|
|
22
18
|
const SERIALIZATION_FAILURE = '40001';
|
|
23
|
-
/** Holds are negotiation-scoped; a week is already generous.
|
|
19
|
+
/** Holds are negotiation-scoped; a week is already generous. */
|
|
24
20
|
const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
25
|
-
/**
|
|
21
|
+
/** Guards against a caller unit error occupying a resource indefinitely. */
|
|
26
22
|
const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
27
|
-
/**
|
|
28
|
-
* The longest leading buffer a row keeps (a day, as bufferBeforeMinutes allows): a row's occupied
|
|
29
|
-
* time starts at most this long before its start, so a read by start can still use the index.
|
|
30
|
-
*/
|
|
23
|
+
/** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
|
|
31
24
|
const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
|
|
32
25
|
export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
|
|
33
26
|
/**
|
|
34
|
-
* Longest one-off event
|
|
35
|
-
*
|
|
36
|
-
* series is bounded by its 367-day materialization window instead.
|
|
27
|
+
* Longest one-off event (ten years and some leap days); a recurring series is bounded by its
|
|
28
|
+
* materialization window instead.
|
|
37
29
|
*/
|
|
38
30
|
export const SLOTLOCK_MAX_EVENT_DURATION_DAYS = 3_660;
|
|
39
31
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
40
32
|
const MAX_CALENDAR_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
|
|
41
33
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* past its end, so that a search of the full 367 days from any moment of the day is complete.
|
|
34
|
+
* The 367-day search window plus two days: the rolling horizon starts a day before the current UTC
|
|
35
|
+
* day and must reach 367 days past its end.
|
|
45
36
|
*/
|
|
46
37
|
export const SLOTLOCK_MATERIALIZATION_WINDOW_DAYS = SLOTLOCK_CALENDAR_HORIZON_DAYS + 2;
|
|
47
38
|
const MAX_MATERIALIZATION_WINDOW_MS = SLOTLOCK_MATERIALIZATION_WINDOW_DAYS * DAY_MS;
|
|
48
39
|
const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * DAY_MS;
|
|
49
40
|
const MAX_RESOURCE_LIST_LIMIT = 1_000;
|
|
50
|
-
/** Highest per-tenant resource cap `createResource` accepts. */
|
|
51
41
|
const MAX_TENANT_RESOURCE_LIMIT = 100_000;
|
|
52
42
|
const MAX_CALENDAR_LIST_LIMIT = 1_000;
|
|
53
43
|
const MAX_FREE_BUSY_INTERVALS = 10_000;
|
|
@@ -66,7 +56,7 @@ const MAX_WAITLIST_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
|
|
|
66
56
|
const DEFAULT_OFFER_TTL_MS = 15 * 60 * 1000;
|
|
67
57
|
/** How long before its start a booking may be checked in. */
|
|
68
58
|
export const SLOTLOCK_CHECK_IN_OPENS_MINUTES = 60;
|
|
69
|
-
/** Free-looking resources one entry tries before the next
|
|
59
|
+
/** Free-looking resources one entry tries before the next: a race can take one first. */
|
|
70
60
|
const OFFER_CANDIDATES = 3;
|
|
71
61
|
/** Default hard ceiling for retained active + tombstoned identities per agent principal. */
|
|
72
62
|
export const SLOTLOCK_AGENT_OWNER_EVENT_QUOTA = 1_000;
|
|
@@ -86,8 +76,8 @@ const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
|
|
|
86
76
|
/** Store-owned coverage source for bounded, locally materialized recurring events. */
|
|
87
77
|
export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
|
|
88
78
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
79
|
+
* From a day before the current UTC day to SLOTLOCK_MATERIALIZATION_WINDOW_DAYS past its end;
|
|
80
|
+
* shared by the writer, maintenance and readiness.
|
|
91
81
|
*/
|
|
92
82
|
export function calendarEventRollingHorizon(now = new Date()) {
|
|
93
83
|
if (!Number.isFinite(now.getTime()))
|
|
@@ -97,8 +87,8 @@ export function calendarEventRollingHorizon(now = new Date()) {
|
|
|
97
87
|
return { start, end: new Date(start.getTime() + MAX_MATERIALIZATION_WINDOW_MS) };
|
|
98
88
|
}
|
|
99
89
|
/**
|
|
100
|
-
* A stored series expanded from
|
|
101
|
-
*
|
|
90
|
+
* A stored series expanded from its last cursor, or `null` when there is no usable cursor or the
|
|
91
|
+
* expansion is refused.
|
|
102
92
|
*/
|
|
103
93
|
function resumeStoredSeries(event, window, found) {
|
|
104
94
|
if (!found?.recurrence_cursor)
|
|
@@ -116,15 +106,13 @@ function resumeStoredSeries(event, window, found) {
|
|
|
116
106
|
throw error;
|
|
117
107
|
}
|
|
118
108
|
}
|
|
119
|
-
/** A series cursor as the jsonb column takes it. */
|
|
120
109
|
function seriesCursorJson(tx, cursor) {
|
|
121
110
|
return cursor === null ? null : tx.json(cursor);
|
|
122
111
|
}
|
|
123
112
|
/**
|
|
124
|
-
*
|
|
125
|
-
* the
|
|
126
|
-
*
|
|
127
|
-
* fits the 367-day cap (it rolls once the horizon is close enough).
|
|
113
|
+
* From the horizon's start to the later of the two ends, so the end never moves back. `null` keeps
|
|
114
|
+
* the stored window: it already covers the horizon, or no window from the horizon's start fits the
|
|
115
|
+
* 367-day cap yet.
|
|
128
116
|
*/
|
|
129
117
|
function rolledWindow(stored, horizon) {
|
|
130
118
|
if (stored.start <= horizon.start && stored.end >= horizon.end)
|
|
@@ -134,21 +122,18 @@ function rolledWindow(stored, horizon) {
|
|
|
134
122
|
return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
|
|
135
123
|
}
|
|
136
124
|
/**
|
|
137
|
-
* How far past a rule's UNTIL, read as UTC, an occurrence can still end
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* whole day where a zone moved across the date line. Three days covers them together.
|
|
125
|
+
* How far past a rule's UNTIL, read as UTC, an occurrence can still end. UNTIL bounds starts (RFC
|
|
126
|
+
* 5545 §3.3.10), but a floating or date-only UNTIL is a wall time in any zone (UTC-12 to UTC+14),
|
|
127
|
+
* skipped hours move starts forward, and clock changes stretch ends: three days covers them
|
|
128
|
+
* together.
|
|
142
129
|
*/
|
|
143
130
|
const SERIES_UNTIL_SLACK_MS = 3 * DAY_MS;
|
|
144
131
|
/** Stored-series windows a COUNT rule is followed through, from its start, to find its end. */
|
|
145
132
|
const SERIES_COUNT_SEARCH_WINDOWS = 30;
|
|
146
133
|
/**
|
|
147
|
-
* A time by which every occurrence of a
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* start, or that search is over its budget. A COUNT series ends a day after its last occurrence as
|
|
151
|
-
* the expansion reads it today, so a later reading of its end may be longer.
|
|
134
|
+
* A time by which every occurrence of a series ends, or `null` when it may go on: no UNTIL or COUNT
|
|
135
|
+
* as ical.js reads it, or the COUNT occurrences are not all found within
|
|
136
|
+
* SERIES_COUNT_SEARCH_WINDOWS windows or the search budget.
|
|
152
137
|
*/
|
|
153
138
|
function seriesEndsBy(event) {
|
|
154
139
|
const recurrence = event.recurrence;
|
|
@@ -185,10 +170,8 @@ function seriesEndsBy(event) {
|
|
|
185
170
|
return Number.isFinite(ends.getTime()) ? ends : null;
|
|
186
171
|
}
|
|
187
172
|
/**
|
|
188
|
-
* The latest end among a COUNT series' occurrences, expanded window by window
|
|
189
|
-
*
|
|
190
|
-
* fewer than `count` turn up. ical.js yields at most COUNT occurrences, so once `count` are found
|
|
191
|
-
* there are no more. An occurrence is counted in the window its start falls in.
|
|
173
|
+
* The latest end among a COUNT series' occurrences, expanded window by window with exceptions left
|
|
174
|
+
* out (so a moved one is found in its own place), or `null` when fewer than `count` turn up.
|
|
192
175
|
*/
|
|
193
176
|
function lastCountedOccurrenceEnd(event, count) {
|
|
194
177
|
const rrule = event.recurrence?.rrule;
|
|
@@ -225,16 +208,15 @@ function lastCountedOccurrenceEnd(event, count) {
|
|
|
225
208
|
return null;
|
|
226
209
|
}
|
|
227
210
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
211
|
+
* recurrence_ends_by as text: NULL for a one-off, 'infinity' for no known end. Bind it as text
|
|
212
|
+
* (`::text::timestamptz`): postgres.js serializes timestamptz through Date, which cannot hold
|
|
213
|
+
* 'infinity'.
|
|
231
214
|
*/
|
|
232
215
|
function recurrenceEndsByColumn(event) {
|
|
233
216
|
if (!event.recurrence)
|
|
234
217
|
return null;
|
|
235
218
|
return seriesEndsBy(event)?.toISOString() ?? 'infinity';
|
|
236
219
|
}
|
|
237
|
-
/** The roll's `after`, checked, or `undefined` for a pass's first batch. */
|
|
238
220
|
function rollCursor(after) {
|
|
239
221
|
if (after === undefined)
|
|
240
222
|
return undefined;
|
|
@@ -250,8 +232,8 @@ function rollCursor(after) {
|
|
|
250
232
|
return after;
|
|
251
233
|
}
|
|
252
234
|
/**
|
|
253
|
-
*
|
|
254
|
-
*
|
|
235
|
+
* A new transaction on a client (READ COMMITTED when asked), or a savepoint inside the caller's,
|
|
236
|
+
* whose isolation cannot change.
|
|
255
237
|
*/
|
|
256
238
|
async function inTransaction(executor, callback, isolation) {
|
|
257
239
|
if ('begin' in executor) {
|
|
@@ -272,12 +254,9 @@ async function assertReadCommitted(tx) {
|
|
|
272
254
|
throw invalidIsolationError();
|
|
273
255
|
}
|
|
274
256
|
/**
|
|
275
|
-
* Run deployment SQL
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* themselves as well; this covers the statements the store sends around them. Every name read
|
|
279
|
-
* before the pin is schema-qualified. The caller's search_path is restored afterwards; a failure
|
|
280
|
-
* aborts the transaction, which reverts the pin as well.
|
|
257
|
+
* Run deployment SQL with names resolved only in pg_catalog and pg_temp. The deployment role owns
|
|
258
|
+
* every table, so a function found in a schema another role can write to ("$user", public) would
|
|
259
|
+
* run with that ownership. The caller's search_path is restored afterwards.
|
|
281
260
|
*/
|
|
282
261
|
async function withPinnedSearchPath(tx, operation) {
|
|
283
262
|
const [caller] = await tx `
|
|
@@ -288,10 +267,8 @@ async function withPinnedSearchPath(tx, operation) {
|
|
|
288
267
|
return result;
|
|
289
268
|
}
|
|
290
269
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* keeping the database's error (and its HINT) as `cause`. It refuses a schema a newer release
|
|
294
|
-
* migrated with SQLSTATE 55000: surface that as `schema_newer`. Any other failure passes through.
|
|
270
|
+
* Map the DDL's refusals: SQLSTATE 42501 (another role controls the schema; findings as JSON in
|
|
271
|
+
* DETAIL) to `unsafe_slotlock_schema`, and 55000 to `schema_newer`. Anything else passes through.
|
|
295
272
|
*/
|
|
296
273
|
function schemaControlRefusal(error) {
|
|
297
274
|
const failure = error;
|
|
@@ -322,7 +299,6 @@ function schemaControlRefusal(error) {
|
|
|
322
299
|
reasons,
|
|
323
300
|
});
|
|
324
301
|
}
|
|
325
|
-
/** PostgreSQL lock_not_available: a lock wait passed lock_timeout. */
|
|
326
302
|
const LOCK_NOT_AVAILABLE = '55P03';
|
|
327
303
|
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 1_000;
|
|
328
304
|
const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
@@ -341,10 +317,10 @@ function migrationSettings(options) {
|
|
|
341
317
|
return { lockTimeoutMs, statementTimeoutMs, attempts };
|
|
342
318
|
}
|
|
343
319
|
/**
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
320
|
+
* One migration step: in a transaction (or savepoint), names pinned to pg_catalog, serialized with
|
|
321
|
+
* every other migration, under the lock and statement timeouts. A lock timeout or deadlock is
|
|
322
|
+
* rolled back and retried up to `attempts` times. The caller's own timeouts are restored
|
|
323
|
+
* afterwards.
|
|
348
324
|
*/
|
|
349
325
|
async function inMigration(executor, options, step) {
|
|
350
326
|
const settings = migrationSettings(options);
|
|
@@ -367,9 +343,8 @@ async function inMigration(executor, options, step) {
|
|
|
367
343
|
}));
|
|
368
344
|
}
|
|
369
345
|
catch (error) {
|
|
370
|
-
// A deadlock
|
|
371
|
-
//
|
|
372
|
-
// it, so it is tried again like a step that timed out waiting.
|
|
346
|
+
// A deadlock victim (a writer took outbox then coverage, a rename batch the other order) lost
|
|
347
|
+
// its locks, so it retries like a lock timeout.
|
|
373
348
|
const code = pgCode(error);
|
|
374
349
|
if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
|
|
375
350
|
throw error;
|
|
@@ -381,12 +356,10 @@ async function inMigration(executor, options, step) {
|
|
|
381
356
|
}
|
|
382
357
|
}
|
|
383
358
|
/**
|
|
384
|
-
* A digest of what the migration defines in the `slotlock` schema:
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* Objects of an extension are left out too. Deparsed definitions depend on search_path, so this runs
|
|
389
|
-
* with it pinned, as the migration does.
|
|
359
|
+
* A digest of what the migration defines in the `slotlock` schema: tables, columns, constraints
|
|
360
|
+
* (and whether validated), indexes, triggers, functions and PUBLIC grants. Grants to other roles,
|
|
361
|
+
* ownership and RLS are left out: applyTenantRls and grantApplicationRole change those later. Runs
|
|
362
|
+
* with search_path pinned, as deparsed definitions depend on it.
|
|
390
363
|
*/
|
|
391
364
|
async function slotlockCatalogDigest(tx) {
|
|
392
365
|
const [row] = await tx `
|
|
@@ -446,10 +419,9 @@ async function slotlockCatalogDigest(tx) {
|
|
|
446
419
|
return row?.digest ?? '';
|
|
447
420
|
}
|
|
448
421
|
/**
|
|
449
|
-
* Whether the schema is exactly what this release's applySchema last left:
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
* what it must.
|
|
422
|
+
* Whether the schema is exactly what this release's applySchema last left: same version, DDL and
|
|
423
|
+
* catalog digest, and no table left unforced by an interrupted migration. Without the right to read
|
|
424
|
+
* it, the full migration runs instead.
|
|
453
425
|
*/
|
|
454
426
|
async function slotlockSchemaUpToDate(tx) {
|
|
455
427
|
const [present] = await tx `
|
|
@@ -476,29 +448,21 @@ async function slotlockSchemaUpToDate(tx) {
|
|
|
476
448
|
}
|
|
477
449
|
/**
|
|
478
450
|
* Rename the coverage changes an earlier release wrote (ddl.ts, slotlockCoverageChangeRenameSql),
|
|
479
|
-
* in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids,
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
* earlier release's server still running then can write more, so every later migration runs a pass
|
|
483
|
-
* too, until one finds nothing to rename: that marks the rename done and no migration reads the
|
|
484
|
-
* outbox for it again.
|
|
451
|
+
* in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, resuming from
|
|
452
|
+
* `schema_version.coverage_changes_renamed_through`. An older server still running can write more,
|
|
453
|
+
* so every migration runs a pass until one finds nothing.
|
|
485
454
|
*
|
|
486
|
-
* The head is read
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* primary-key range of the outbox. A table an operator left unforced stays so. A superuser or
|
|
492
|
-
* BYPASSRLS role reads every row as it is: it holds SHARE ROW EXCLUSIVE on the outbox (which waits
|
|
493
|
-
* for the writes in flight and holds off new ones, but no read) only for the short transaction that
|
|
494
|
-
* reads the head. Changes an earlier release's server writes after the upgrade are put in shape as
|
|
495
|
-
* they are inserted (ddl.ts, slotlock.outbox_coverage_in_shape); the passes are for those before.
|
|
455
|
+
* The head is read once every transaction already writing to the outbox has ended, so nothing at or
|
|
456
|
+
* below it commits later. Forced RLS would hide other tenants' rows, so each batch lifts FORCE from
|
|
457
|
+
* calendar_coverage and outbox under ACCESS EXCLUSIVE (in writer lock order) and restores it before
|
|
458
|
+
* committing. A superuser or BYPASSRLS role instead holds SHARE ROW EXCLUSIVE on the outbox only
|
|
459
|
+
* while reading the head.
|
|
496
460
|
*/
|
|
497
461
|
async function renameEarlierCoverageChanges(executor, options) {
|
|
498
462
|
/**
|
|
499
|
-
* Run `work` able to read every tenant's
|
|
500
|
-
*
|
|
501
|
-
*
|
|
463
|
+
* Run `work` able to read every tenant's rows: as is for a superuser or BYPASSRLS role, else with
|
|
464
|
+
* FORCE lifted under ACCESS EXCLUSIVE (calendar_coverage, then outbox: writer lock order) and put
|
|
465
|
+
* back after.
|
|
502
466
|
*/
|
|
503
467
|
const everyTenant = async (tx, bypass, work) => {
|
|
504
468
|
const forced = bypass
|
|
@@ -521,8 +485,8 @@ async function renameEarlierCoverageChanges(executor, options) {
|
|
|
521
485
|
await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
|
522
486
|
return result;
|
|
523
487
|
};
|
|
524
|
-
// The
|
|
525
|
-
//
|
|
488
|
+
// The head, in its own short transaction: writes in flight hold ROW EXCLUSIVE from before they
|
|
489
|
+
// took their id, so none at or below it commits later.
|
|
526
490
|
const start = await inMigration(executor, options, async (tx) => {
|
|
527
491
|
const [state] = await tx `
|
|
528
492
|
SELECT version.coverage_changes_renamed AS renamed,
|
|
@@ -554,7 +518,6 @@ async function renameEarlierCoverageChanges(executor, options) {
|
|
|
554
518
|
]));
|
|
555
519
|
const read = row?.last ?? null;
|
|
556
520
|
renamedTotal += row?.renamed ?? 0;
|
|
557
|
-
// Saved with the batch, so a pass stopped part way goes on from here.
|
|
558
521
|
await tx `
|
|
559
522
|
UPDATE slotlock.schema_version
|
|
560
523
|
SET coverage_changes_renamed_through = ${read ?? head}::bigint,
|
|
@@ -567,7 +530,6 @@ async function renameEarlierCoverageChanges(executor, options) {
|
|
|
567
530
|
after = String(last);
|
|
568
531
|
}
|
|
569
532
|
}
|
|
570
|
-
/** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
|
|
571
533
|
async function coverageChangesRenamePending(tx) {
|
|
572
534
|
const [row] = await tx `
|
|
573
535
|
SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
|
|
@@ -586,8 +548,8 @@ async function slotlockConstraintUnvalidated(tx) {
|
|
|
586
548
|
return row?.unvalidated === true;
|
|
587
549
|
}
|
|
588
550
|
/**
|
|
589
|
-
* Whether every tenant table has forced RLS and only its tenant policy
|
|
590
|
-
*
|
|
551
|
+
* Whether every tenant table has forced RLS and only its tenant policy bound to `setting`, matching
|
|
552
|
+
* the fingerprint the DDL recorded.
|
|
591
553
|
*/
|
|
592
554
|
async function tenantRlsUpToDate(tx, setting) {
|
|
593
555
|
const [present] = await tx `
|
|
@@ -622,9 +584,8 @@ async function tenantRlsUpToDate(tx, setting) {
|
|
|
622
584
|
return state?.current === true && state.matched === tables.length;
|
|
623
585
|
}
|
|
624
586
|
/**
|
|
625
|
-
* The schema version
|
|
626
|
-
*
|
|
627
|
-
* `slotlock migrate`, which grants it).
|
|
587
|
+
* The recorded schema version, or `null` when this role cannot read one: no schema, 0.2 or earlier,
|
|
588
|
+
* or the function not yet granted (`slotlock migrate` grants it).
|
|
628
589
|
*/
|
|
629
590
|
export async function readSlotlockSchemaVersion(sql) {
|
|
630
591
|
const [readable] = await sql `
|
|
@@ -638,9 +599,8 @@ export async function readSlotlockSchemaVersion(sql) {
|
|
|
638
599
|
return row?.version ?? null;
|
|
639
600
|
}
|
|
640
601
|
/**
|
|
641
|
-
* Refuse
|
|
642
|
-
*
|
|
643
|
-
* migrated it, whose schema this release's code does not know. Both carry `installed` and `expected`.
|
|
602
|
+
* Refuse a schema another release migrated: `schema_outdated` (older or unversioned: run `slotlock
|
|
603
|
+
* migrate`) or `schema_newer`. Both carry `installed` and `expected`.
|
|
644
604
|
*/
|
|
645
605
|
export async function assertSlotlockSchemaCurrent(sql) {
|
|
646
606
|
const installed = await readSlotlockSchemaVersion(sql);
|
|
@@ -747,7 +707,6 @@ function toHold(row) {
|
|
|
747
707
|
...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
|
|
748
708
|
};
|
|
749
709
|
}
|
|
750
|
-
/** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
|
|
751
710
|
function pageCursor(after) {
|
|
752
711
|
if (after !== undefined &&
|
|
753
712
|
(!(after.start instanceof Date) ||
|
|
@@ -1012,7 +971,6 @@ async function replaceCalendarEventOccurrences(tx, params) {
|
|
|
1012
971
|
)`;
|
|
1013
972
|
}
|
|
1014
973
|
}
|
|
1015
|
-
/** A recurring series' materialization window: finite, forward, and at most 369 days. */
|
|
1016
974
|
function seriesWindow(window) {
|
|
1017
975
|
if (!isFiniteWindow(window) ||
|
|
1018
976
|
window.end.getTime() - window.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS) {
|
|
@@ -1028,9 +986,9 @@ function calendarWindow(window) {
|
|
|
1028
986
|
return { start: new Date(window.start), end: new Date(window.end) };
|
|
1029
987
|
}
|
|
1030
988
|
/**
|
|
1031
|
-
* A one-off event's
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
989
|
+
* A one-off event's window must contain the event. It may exceed the 367-day cap only by being
|
|
990
|
+
* exactly the event's interval, as the `slotlock_calendar_events_materialization_window_bounded`
|
|
991
|
+
* CHECK admits.
|
|
1034
992
|
*/
|
|
1035
993
|
function oneOffEventWindow(event, requested) {
|
|
1036
994
|
const duration = event.end.getTime() - event.start.getTime();
|
|
@@ -1049,17 +1007,15 @@ function calendarCommandHash(value) {
|
|
|
1049
1007
|
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
1050
1008
|
}
|
|
1051
1009
|
/**
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
* midnight: with it in the hash, a retry after midnight was refused as a different request.
|
|
1010
|
+
* A put's identity and the event it asks for, not its storage window: a series' window moves with
|
|
1011
|
+
* the UTC day, so a retry after midnight must still match.
|
|
1055
1012
|
*/
|
|
1056
1013
|
function putCommandHash(params) {
|
|
1057
1014
|
return calendarCommandHash(putCommandValue(params));
|
|
1058
1015
|
}
|
|
1059
1016
|
/**
|
|
1060
|
-
* The hash 0.3.0 and earlier stored
|
|
1061
|
-
*
|
|
1062
|
-
* for a series. Remove once no stored command can be that old (commands are pruned after 30 days).
|
|
1017
|
+
* The hash 0.3.0 and earlier stored (window included), so an older stored command still replays.
|
|
1018
|
+
* Remove once no stored command can be that old (pruned after 30 days).
|
|
1063
1019
|
*/
|
|
1064
1020
|
export function legacyPutCommandHash(params) {
|
|
1065
1021
|
return calendarCommandHash({
|
|
@@ -1079,7 +1035,7 @@ function putCommandValue(params) {
|
|
|
1079
1035
|
expectedRevision: params.command.expectedRevision,
|
|
1080
1036
|
resourceId: params.command.resourceId,
|
|
1081
1037
|
source: params.command.source ?? null,
|
|
1082
|
-
// Only when present, so a create without a hold keeps
|
|
1038
|
+
// Only when present, so a create without a hold keeps its hash.
|
|
1083
1039
|
...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
|
|
1084
1040
|
event: {
|
|
1085
1041
|
...params.event,
|
|
@@ -1138,8 +1094,7 @@ async function calendarOwnerCommandCount(tx, tenantRef, ownerRef) {
|
|
|
1138
1094
|
}
|
|
1139
1095
|
/**
|
|
1140
1096
|
* The end of every event cancellation, under the event's identity and row locks: remove the event
|
|
1141
|
-
*
|
|
1142
|
-
* the command in `commandOwnerRef`'s namespace for replay.
|
|
1097
|
+
* and its occupation, leave the tombstone at `revision`, and record the command for replay.
|
|
1143
1098
|
*/
|
|
1144
1099
|
async function finishCalendarCancel(tx, params) {
|
|
1145
1100
|
const eventId = params.eventId ?? randomUUID();
|
|
@@ -1179,8 +1134,8 @@ async function finishCalendarCancel(tx, params) {
|
|
|
1179
1134
|
};
|
|
1180
1135
|
}
|
|
1181
1136
|
/**
|
|
1182
|
-
* The row an event write lost to. The write was rolled back, so the event's own rows
|
|
1183
|
-
*
|
|
1137
|
+
* The row an event write lost to. The write was rolled back, so the event's own previous rows are
|
|
1138
|
+
* excluded: they were to be replaced.
|
|
1184
1139
|
*/
|
|
1185
1140
|
async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
|
|
1186
1141
|
for (const occurrence of occurrences) {
|
|
@@ -1191,10 +1146,9 @@ async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffer
|
|
|
1191
1146
|
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
1192
1147
|
}
|
|
1193
1148
|
/**
|
|
1194
|
-
* What the resource's booking rules
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* hours is judged against `defaultHours`.
|
|
1149
|
+
* What the resource's effective booking rules say about booking `interval` now (database clock):
|
|
1150
|
+
* the rule broken, if any, and the buffers to keep. A resource without its own hours is judged
|
|
1151
|
+
* against `defaultHours`.
|
|
1198
1152
|
*/
|
|
1199
1153
|
async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
1200
1154
|
const judge = await bookingJudge(tx, resourceId, defaultHours);
|
|
@@ -1207,8 +1161,8 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
|
1207
1161
|
/** How recently a sync must have read a resource whose booking rules require certainty. */
|
|
1208
1162
|
const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
1209
1163
|
/**
|
|
1210
|
-
* The resource's booking rules
|
|
1211
|
-
*
|
|
1164
|
+
* The resource's effective booking rules, read once, as a check of any interval booked now
|
|
1165
|
+
* (database clock) and the buffers to keep.
|
|
1212
1166
|
*/
|
|
1213
1167
|
async function bookingJudge(tx, resourceId, defaultHours) {
|
|
1214
1168
|
const [row] = await tx `
|
|
@@ -1231,7 +1185,6 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1231
1185
|
effective_booking_rules: row.effective_booking_rules,
|
|
1232
1186
|
});
|
|
1233
1187
|
const hours = availabilityRules ?? defaultHours;
|
|
1234
|
-
// Every sync covering the resource, and whether it was read within a day, by the same clock.
|
|
1235
1188
|
const coverage = rules?.requireCertain === true
|
|
1236
1189
|
? await tx `
|
|
1237
1190
|
SELECT starts_at, ends_at,
|
|
@@ -1241,12 +1194,11 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1241
1194
|
: [];
|
|
1242
1195
|
const turnaroundMs = bookingBufferAfterMs(rules);
|
|
1243
1196
|
const bufferBeforeMs = bookingBufferBeforeMs(rules);
|
|
1244
|
-
// The booking and the buffers it keeps, as a search judges the slot.
|
|
1245
1197
|
const uncertain = (interval) => coverage.some((source) => !source.fresh ||
|
|
1246
1198
|
source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
|
|
1247
1199
|
source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1200
|
+
// Later occurrences are judged on step and hours only: notice and advance concern when the
|
|
1201
|
+
// booking was made.
|
|
1250
1202
|
const later = rules === undefined
|
|
1251
1203
|
? undefined
|
|
1252
1204
|
: {
|
|
@@ -1271,19 +1223,17 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
|
1271
1223
|
return (row.resource_id === params.resourceId &&
|
|
1272
1224
|
row.starts_at.getTime() === params.start.getTime() &&
|
|
1273
1225
|
row.ends_at.getTime() === params.end.getTime() &&
|
|
1274
|
-
// As asked, or raised to the resource's turnaround when it was stored.
|
|
1275
1226
|
(buffer === params.bufferAfterMs || buffer === Math.max(params.bufferAfterMs, turnaroundMs)) &&
|
|
1276
1227
|
Number(row.revision) === params.revision &&
|
|
1277
1228
|
row.source === (params.source ?? null));
|
|
1278
1229
|
}
|
|
1279
1230
|
async function lockExternalIdentity(tx, tenantRef, externalRef) {
|
|
1280
|
-
// JSON
|
|
1281
|
-
// U+0000). The database hashes it to one transaction-scoped lock key.
|
|
1231
|
+
// JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
|
|
1282
1232
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
|
|
1283
1233
|
}
|
|
1284
1234
|
/**
|
|
1285
|
-
* Whether the resource is the tenant's (`missing` when not), and whether it is archived.
|
|
1286
|
-
*
|
|
1235
|
+
* Whether the resource is the tenant's (`missing` when not), and whether it is archived.
|
|
1236
|
+
* Share-locked to the end of the transaction, so it is neither archived nor deleted meanwhile.
|
|
1287
1237
|
*/
|
|
1288
1238
|
async function tenantResourceState(tx, resourceId, tenantRef) {
|
|
1289
1239
|
const [row] = await tx `
|
|
@@ -1333,9 +1283,9 @@ function invalidPoolError() {
|
|
|
1333
1283
|
});
|
|
1334
1284
|
}
|
|
1335
1285
|
/**
|
|
1336
|
-
* A resource row's booking rules
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1286
|
+
* A resource row's effective booking rules (jsonb; NULL for none): tenant defaults, overlaid by its
|
|
1287
|
+
* pool's, overlaid by its own, field by field. Reads from a row of slotlock.resources named
|
|
1288
|
+
* `resources` (no alias).
|
|
1339
1289
|
*/
|
|
1340
1290
|
const EFFECTIVE_BOOKING_RULES = `NULLIF(
|
|
1341
1291
|
COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
|
|
@@ -1347,7 +1297,7 @@ const EFFECTIVE_BOOKING_RULES = `NULLIF(
|
|
|
1347
1297
|
'{}'::jsonb) AS effective_booking_rules`;
|
|
1348
1298
|
/** What rules Slotlock did not write read as: no booking passes them. */
|
|
1349
1299
|
const REFUSE_EVERY_BOOKING = Object.freeze({ maxAdvanceDays: 1, minNoticeMinutes: 525_600 });
|
|
1350
|
-
/** Rules
|
|
1300
|
+
/** Rules written validated, so anything else read back refuses every booking. */
|
|
1351
1301
|
function storedBookingRules(value) {
|
|
1352
1302
|
try {
|
|
1353
1303
|
return parseBookingRules(value);
|
|
@@ -1369,7 +1319,7 @@ function toResource(row) {
|
|
|
1369
1319
|
if (row.pool_ref !== null && row.pool_ref !== undefined)
|
|
1370
1320
|
resource.pool = row.pool_ref;
|
|
1371
1321
|
if (row.availability_rules !== null && row.availability_rules !== undefined) {
|
|
1372
|
-
// Written
|
|
1322
|
+
// Written validated; anything else reads as closed.
|
|
1373
1323
|
try {
|
|
1374
1324
|
resource.availabilityRules = parseAvailabilityRules(row.availability_rules);
|
|
1375
1325
|
}
|
|
@@ -1378,14 +1328,12 @@ function toResource(row) {
|
|
|
1378
1328
|
}
|
|
1379
1329
|
}
|
|
1380
1330
|
if (row.booking_rules !== null && row.booking_rules !== undefined) {
|
|
1381
|
-
// Written
|
|
1382
|
-
// every booking, never as no rule.
|
|
1331
|
+
// Written validated; anything else refuses every booking, never as no rule.
|
|
1383
1332
|
resource.bookingRules = storedBookingRules(row.booking_rules);
|
|
1384
1333
|
}
|
|
1385
1334
|
if (row.effective_booking_rules !== null && row.effective_booking_rules !== undefined) {
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
// invalid in itself was not written by Slotlock, and refuses every booking, as stored rules do.
|
|
1335
|
+
// Levels are validated alone, so only their combination can disagree (an offset over a shorter
|
|
1336
|
+
// step): that one is read modulo the step. A field invalid in itself refuses every booking.
|
|
1389
1337
|
const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
|
|
1390
1338
|
if (dropped.length > 0) {
|
|
1391
1339
|
console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
|
|
@@ -1397,7 +1345,6 @@ function toResource(row) {
|
|
|
1397
1345
|
}
|
|
1398
1346
|
return resource;
|
|
1399
1347
|
}
|
|
1400
|
-
/** Validated rules as a jsonb parameter, or null for "use the default". */
|
|
1401
1348
|
function availabilityParameter(sql, rules) {
|
|
1402
1349
|
// sql.json, not JSON.stringify: a string bound to jsonb is stored as a JSON string.
|
|
1403
1350
|
if (rules === null)
|
|
@@ -1412,8 +1359,8 @@ async function upsertResource(executor, params) {
|
|
|
1412
1359
|
const visibility = params.visibility ?? 'shared';
|
|
1413
1360
|
const asked = params.visibility ?? null;
|
|
1414
1361
|
let rows;
|
|
1415
|
-
// An existing identity keeps
|
|
1416
|
-
//
|
|
1362
|
+
// An existing identity keeps its visibility: re-creating with another is refused, so a private
|
|
1363
|
+
// one is never made shared.
|
|
1417
1364
|
if (params.tenantRef !== undefined && params.externalRef !== undefined) {
|
|
1418
1365
|
rows = await executor `
|
|
1419
1366
|
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
@@ -1449,11 +1396,9 @@ async function upsertResource(executor, params) {
|
|
|
1449
1396
|
return toResource(row);
|
|
1450
1397
|
}
|
|
1451
1398
|
/**
|
|
1452
|
-
* Rematerialize one stored recurring master over
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
* and a rule over the expansion budget CalendarExpansionLimitError; run it in a savepoint, which
|
|
1456
|
-
* either rolls back whole.
|
|
1399
|
+
* Rematerialize one stored recurring master over rolledWindow: from its last expansion's cursor
|
|
1400
|
+
* when trustworthy, else its start. False when there is nothing to roll. A collision throws 23P01
|
|
1401
|
+
* and an over-budget rule CalendarExpansionLimitError; run it in a savepoint.
|
|
1457
1402
|
*/
|
|
1458
1403
|
async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
1459
1404
|
const [row] = await tx `
|
|
@@ -1499,8 +1444,8 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1499
1444
|
return true;
|
|
1500
1445
|
}
|
|
1501
1446
|
/**
|
|
1502
|
-
* Work out and store when a series stored before 0.3 ends
|
|
1503
|
-
*
|
|
1447
|
+
* Work out and store when a series stored before 0.3 ends, as its next roll would; its occurrences
|
|
1448
|
+
* are left as they are.
|
|
1504
1449
|
*/
|
|
1505
1450
|
async function learnSeriesEnd(tx, tenantRef, eventId) {
|
|
1506
1451
|
const [row] = await tx `
|
|
@@ -1522,7 +1467,6 @@ async function learnSeriesEnd(tx, tenantRef, eventId) {
|
|
|
1522
1467
|
SET recurrence_ends_by = ${recurrenceEndsByColumn(event)}::text::timestamptz
|
|
1523
1468
|
WHERE id = ${row.id} AND tenant_ref = ${tenantRef} AND recurrence_ends_by IS NULL`;
|
|
1524
1469
|
}
|
|
1525
|
-
/** Put a master the roll could not extend behind the others for an hour. */
|
|
1526
1470
|
async function deferRoll(executor, tenantRef, eventId) {
|
|
1527
1471
|
await executor `
|
|
1528
1472
|
UPDATE slotlock.calendar_events
|
|
@@ -1531,24 +1475,19 @@ async function deferRoll(executor, tenantRef, eventId) {
|
|
|
1531
1475
|
}
|
|
1532
1476
|
/**
|
|
1533
1477
|
* Whether every opaque recurring series on the resource (but `exceptEventId`) is stored over the
|
|
1534
|
-
* part of [start, end)
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
* write meets its occurrences under the exclusion arbiter; false when one cannot be (it goes on
|
|
1540
|
-
* past the horizon and so does the write, it collides, or its rule is over the budget). Series are
|
|
1541
|
-
* rolled in id order; an event's update holds its own row while it rolls the others, so it and a
|
|
1542
|
-
* write rolling the same series in that order can deadlock, which the database breaks (the writer
|
|
1543
|
-
* retries once, then answers an overlap).
|
|
1478
|
+
* part of [start, end) a roll can reach. A write must not land where a series is not stored yet, or
|
|
1479
|
+
* the series' next roll would collide with it. A series behind is first rolled to the horizon in a
|
|
1480
|
+
* savepoint, so the write meets its occurrences under the exclusion arbiter; false when one cannot
|
|
1481
|
+
* be. Series roll in id order; a deadlock with a concurrent writer is broken by the database
|
|
1482
|
+
* (retried once, then an overlap).
|
|
1544
1483
|
*/
|
|
1545
1484
|
async function recurrenceCoversWrite(tx, params) {
|
|
1546
1485
|
const horizon = calendarEventRollingHorizon();
|
|
1547
1486
|
if (params.end <= horizon.start)
|
|
1548
1487
|
return true;
|
|
1549
1488
|
const from = params.start > horizon.start ? params.start : horizon.start;
|
|
1550
|
-
// LEAST and GREATEST ignore
|
|
1551
|
-
//
|
|
1489
|
+
// LEAST and GREATEST ignore NULL arguments (PostgreSQL 16): a series with no known end is behind
|
|
1490
|
+
// wherever it is stored short of the write's end.
|
|
1552
1491
|
const behind = () => tx `
|
|
1553
1492
|
SELECT event.id, event.recurrence_ends_by IS NULL AS ends_by_unknown,
|
|
1554
1493
|
COALESCE(event.recurrence_ends_by <= ${horizon.end}::timestamptz, false)
|
|
@@ -1578,8 +1517,8 @@ async function recurrenceCoversWrite(tx, params) {
|
|
|
1578
1517
|
if (due.length === 0)
|
|
1579
1518
|
return true;
|
|
1580
1519
|
if (params.end > horizon.end) {
|
|
1581
|
-
// A series stored before 0.3
|
|
1582
|
-
//
|
|
1520
|
+
// A series stored before 0.3 works out its end now, and is left out if it ends before the write
|
|
1521
|
+
// or is stored through its end.
|
|
1583
1522
|
if (due.some((row) => row.ends_by_unknown)) {
|
|
1584
1523
|
for (const { id, ends_by_unknown } of due) {
|
|
1585
1524
|
if (ends_by_unknown)
|
|
@@ -1589,7 +1528,6 @@ async function recurrenceCoversWrite(tx, params) {
|
|
|
1589
1528
|
if (due.length === 0)
|
|
1590
1529
|
return true;
|
|
1591
1530
|
}
|
|
1592
|
-
// No roll stores a series past the horizon: one going on past it stays behind such a write.
|
|
1593
1531
|
if (due.some((row) => !row.ends_in_horizon))
|
|
1594
1532
|
return false;
|
|
1595
1533
|
}
|
|
@@ -1607,9 +1545,8 @@ async function recurrenceCoversWrite(tx, params) {
|
|
|
1607
1545
|
return (await behind()).length === 0;
|
|
1608
1546
|
}
|
|
1609
1547
|
/**
|
|
1610
|
-
* Whether an event already occupies
|
|
1611
|
-
*
|
|
1612
|
-
* to where the series on the resource are stored.
|
|
1548
|
+
* Whether an event already occupies all the time `occurrences` would: such a write (a rename) takes
|
|
1549
|
+
* no time a series could later need.
|
|
1613
1550
|
*/
|
|
1614
1551
|
async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
|
|
1615
1552
|
const held = await tx `
|
|
@@ -1620,7 +1557,6 @@ async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
|
|
|
1620
1557
|
AND reservation.resource_id = ${resourceId}`;
|
|
1621
1558
|
return occurrences.every((occurrence) => held.some(({ start, end }) => start <= occurrence.start && occurrence.end <= end));
|
|
1622
1559
|
}
|
|
1623
|
-
/** From the earliest start to the latest end of an event's occurrences, or null for none. */
|
|
1624
1560
|
function occurrenceSpan(occurrences) {
|
|
1625
1561
|
const [first] = occurrences;
|
|
1626
1562
|
if (!first)
|
|
@@ -1636,10 +1572,9 @@ function occurrenceSpan(occurrences) {
|
|
|
1636
1572
|
return { start, end };
|
|
1637
1573
|
}
|
|
1638
1574
|
/**
|
|
1639
|
-
* After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
* it fails, the series are left to maintenance and the write is still answered as a loss.
|
|
1575
|
+
* After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series the
|
|
1576
|
+
* write's transaction rolled and lost, so the loss can name the blocking occurrence. Upkeep only:
|
|
1577
|
+
* on failure, maintenance rolls them later.
|
|
1643
1578
|
*/
|
|
1644
1579
|
async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId) {
|
|
1645
1580
|
try {
|
|
@@ -1677,35 +1612,30 @@ function tenantLimitsOption(limits) {
|
|
|
1677
1612
|
return valid;
|
|
1678
1613
|
}
|
|
1679
1614
|
/**
|
|
1680
|
-
* Serialize
|
|
1681
|
-
*
|
|
1615
|
+
* Serialize a tenant's writes adding rows of one limited kind, so each counts what earlier ones
|
|
1616
|
+
* committed. Taken after the identity locks, before any row lock.
|
|
1682
1617
|
*/
|
|
1683
1618
|
async function lockTenantLimit(tx, tenantRef, kind) {
|
|
1684
1619
|
// An object, so the key never equals a [tenantRef, externalRef] identity lock's.
|
|
1685
1620
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ tenantLimit: kind, tenantRef })}, 0))`;
|
|
1686
1621
|
}
|
|
1687
1622
|
/**
|
|
1688
|
-
* Rows
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
* event idempotency records and stray retirements at least as long (the store's `retentionDays`).
|
|
1623
|
+
* Rows added within this many days count whatever time they hold, so back-dated bookings and
|
|
1624
|
+
* cancellations add at most the limit per window. Maintenance keeps idempotency records and stray
|
|
1625
|
+
* retirements at least this long.
|
|
1692
1626
|
*/
|
|
1693
1627
|
const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
|
|
1694
1628
|
/**
|
|
1695
|
-
* The rows a tenant
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
* however much history the tenant keeps; the total is at least `limit` exactly when the tenant is
|
|
1699
|
-
* at it.
|
|
1629
|
+
* The rows a tenant's `kind` limit counts: what is still to come plus what it added in the last
|
|
1630
|
+
* TENANT_LIMIT_RECENT_DAYS days. Each part stops at `limit` rows read through an index, so a count
|
|
1631
|
+
* costs at most the limit.
|
|
1700
1632
|
*/
|
|
1701
1633
|
async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
1702
1634
|
let rows;
|
|
1703
1635
|
switch (kind) {
|
|
1704
1636
|
case 'holds':
|
|
1705
|
-
// Every held row
|
|
1706
|
-
//
|
|
1707
|
-
// each keyed hold no longer held (released or confirmed, or its expired row deleted) until it
|
|
1708
|
-
// is forgotten. An answer whose hold is still a held row is left out: that row counts.
|
|
1637
|
+
// Every held row until deleted (expired ones too), plus each keyed hold's kept answer once
|
|
1638
|
+
// its row is gone.
|
|
1709
1639
|
rows = await tx `
|
|
1710
1640
|
SELECT (
|
|
1711
1641
|
(SELECT count(*) FROM (
|
|
@@ -1725,9 +1655,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
|
1725
1655
|
)::int AS count`;
|
|
1726
1656
|
break;
|
|
1727
1657
|
case 'reservations':
|
|
1728
|
-
// Bookings still to come; bookings
|
|
1729
|
-
//
|
|
1730
|
-
// tombstone is kept for good, and stops counting once it is no longer recent.
|
|
1658
|
+
// Bookings still to come; ended bookings and cancellations made recently; and stray
|
|
1659
|
+
// retirements until pruned.
|
|
1731
1660
|
rows = await tx `
|
|
1732
1661
|
WITH recent AS (
|
|
1733
1662
|
SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
|
|
@@ -1755,9 +1684,9 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
|
1755
1684
|
)::int AS count`;
|
|
1756
1685
|
break;
|
|
1757
1686
|
case 'events':
|
|
1758
|
-
// One-off events still to come and series not known to have ended;
|
|
1759
|
-
// and cancellations
|
|
1760
|
-
//
|
|
1687
|
+
// One-off events still to come and series not known to have ended; recent ended events and
|
|
1688
|
+
// cancellations; and agent cancellations until pruned. The owner CHECK makes every owner but
|
|
1689
|
+
// 'internal' an 'agent:' one.
|
|
1761
1690
|
rows = await tx `
|
|
1762
1691
|
WITH recent AS (
|
|
1763
1692
|
SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
|
|
@@ -1802,9 +1731,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
|
1802
1731
|
return count;
|
|
1803
1732
|
}
|
|
1804
1733
|
/**
|
|
1805
|
-
* Delete up to `limit` of a tenant's holds
|
|
1806
|
-
*
|
|
1807
|
-
* predicate on the row as it now is.
|
|
1734
|
+
* Delete up to `limit` of a tenant's expired holds, oldest first. Rows a reaper or confirmer has
|
|
1735
|
+
* locked are skipped, and the delete rechecks the predicate.
|
|
1808
1736
|
*/
|
|
1809
1737
|
async function deleteExpiredHolds(tx, tenantRef, limit) {
|
|
1810
1738
|
const [row] = await tx `
|
|
@@ -1828,8 +1756,8 @@ async function deleteExpiredHolds(tx, tenantRef, limit) {
|
|
|
1828
1756
|
return Number(row?.deleted ?? 0);
|
|
1829
1757
|
}
|
|
1830
1758
|
/**
|
|
1831
|
-
* Delete up to `limit`
|
|
1832
|
-
*
|
|
1759
|
+
* Delete up to `limit` keyed hold answers whose hold has expired, so a retry makes a new hold;
|
|
1760
|
+
* locked answers are skipped.
|
|
1833
1761
|
*/
|
|
1834
1762
|
async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
|
|
1835
1763
|
const [row] = await tx `
|
|
@@ -1852,8 +1780,8 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
|
|
|
1852
1780
|
return Number(row?.deleted ?? 0);
|
|
1853
1781
|
}
|
|
1854
1782
|
/**
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
1783
|
+
* A booking's occupied time as the exclusion constraint reads it: start less its leading buffer to
|
|
1784
|
+
* end plus its trailing one.
|
|
1857
1785
|
*/
|
|
1858
1786
|
function occupiedTime(params) {
|
|
1859
1787
|
return {
|
|
@@ -1862,8 +1790,8 @@ function occupiedTime(params) {
|
|
|
1862
1790
|
};
|
|
1863
1791
|
}
|
|
1864
1792
|
/**
|
|
1865
|
-
*
|
|
1866
|
-
*
|
|
1793
|
+
* Occupied start before `end`, written so the (resource_id, starts_at, ends_at) index still
|
|
1794
|
+
* applies: no row starts more than a day after its occupied start.
|
|
1867
1795
|
*/
|
|
1868
1796
|
function occupiedStartsBefore(executor, end, alias) {
|
|
1869
1797
|
const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
|
|
@@ -1871,12 +1799,9 @@ function occupiedStartsBefore(executor, end, alias) {
|
|
|
1871
1799
|
AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
|
|
1872
1800
|
}
|
|
1873
1801
|
/**
|
|
1874
|
-
* Delete expired held rows whose occupied time
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
* order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
|
|
1878
|
-
* deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
|
|
1879
|
-
* before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
|
|
1802
|
+
* Delete expired held rows whose occupied time overlaps the window, in id order under FOR UPDATE:
|
|
1803
|
+
* concurrent reapers lock in the same order (no deadlock) and the loser finds them gone. Runs right
|
|
1804
|
+
* before the caller's INSERT, so the EXCLUDE arbiter only sees live occupation.
|
|
1880
1805
|
*/
|
|
1881
1806
|
async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
1882
1807
|
await tx `
|
|
@@ -1893,10 +1818,9 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
1893
1818
|
)`;
|
|
1894
1819
|
}
|
|
1895
1820
|
/**
|
|
1896
|
-
* Structured answer after losing to the EXCLUDE arbiter: name the
|
|
1897
|
-
* conflictingReservationId
|
|
1898
|
-
*
|
|
1899
|
-
* immediate retry reaps it and wins.
|
|
1821
|
+
* Structured answer after losing to the EXCLUDE arbiter: name the live winner.
|
|
1822
|
+
* conflictingReservationId is null in one narrow race (the blocker expired between the constraint
|
|
1823
|
+
* check and this lookup); an immediate retry reaps it and wins.
|
|
1900
1824
|
*/
|
|
1901
1825
|
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
|
|
1902
1826
|
const winners = await sql `
|
|
@@ -1967,7 +1891,7 @@ export function createTenantSlots(max) {
|
|
|
1967
1891
|
if (released)
|
|
1968
1892
|
return;
|
|
1969
1893
|
released = true;
|
|
1970
|
-
// Handed straight to the next in line, so the count
|
|
1894
|
+
// Handed straight to the next in line, so the count is unchanged.
|
|
1971
1895
|
const next = slots.waiting.shift();
|
|
1972
1896
|
if (next) {
|
|
1973
1897
|
next();
|
|
@@ -2033,9 +1957,8 @@ function staleSuspensionCheckError() {
|
|
|
2033
1957
|
return Object.assign(new Error('Slotlock writes for a tenant only in a READ COMMITTED transaction, whose suspension check sees every suspension committed before the tenant lock'), { code: 'invalid_transaction_isolation' });
|
|
2034
1958
|
}
|
|
2035
1959
|
/**
|
|
2036
|
-
* Reading a
|
|
2037
|
-
*
|
|
2038
|
-
* Name the remedy for each; any other failure passes through.
|
|
1960
|
+
* Reading a suspension needs EXECUTE on slotlock.tenant_suspended(text) (42501 without it) and a
|
|
1961
|
+
* schema this release migrated (42883 before). Name the remedy for each.
|
|
2039
1962
|
*/
|
|
2040
1963
|
function suspensionCheckRefusal(error) {
|
|
2041
1964
|
const code = pgCode(error);
|
|
@@ -2048,8 +1971,8 @@ function suspensionCheckRefusal(error) {
|
|
|
2048
1971
|
return error;
|
|
2049
1972
|
}
|
|
2050
1973
|
/**
|
|
2051
|
-
* A store on `sql`. With `refusal`, every method
|
|
2052
|
-
*
|
|
1974
|
+
* A store on `sql`. With `refusal`, every SLOTLOCK_TENANT_WRITE_METHODS method throws it before any
|
|
1975
|
+
* query.
|
|
2053
1976
|
*/
|
|
2054
1977
|
function buildSlotlockStore(sql, options, refusal) {
|
|
2055
1978
|
const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
|
|
@@ -2096,7 +2019,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2096
2019
|
? createTenantSlots(maxTenantTransactions)
|
|
2097
2020
|
: undefined;
|
|
2098
2021
|
const lockTimeout = ownTransactions && tenantLockTimeoutMs !== undefined ? `${tenantLockTimeoutMs}ms` : undefined;
|
|
2099
|
-
createSlotlockTenantRlsDdl(configuredTenantSetting);
|
|
2022
|
+
createSlotlockTenantRlsDdl(configuredTenantSetting);
|
|
2100
2023
|
const storeOptions = {
|
|
2101
2024
|
tenantContextSetting: configuredTenantSetting,
|
|
2102
2025
|
agentOwnerEventQuota: configuredAgentOwnerEventQuota,
|
|
@@ -2107,9 +2030,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2107
2030
|
...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
|
|
2108
2031
|
};
|
|
2109
2032
|
/**
|
|
2110
|
-
* Under the tenant's lock for `kind`: the limit when the tenant is at it, else null.
|
|
2111
|
-
*
|
|
2112
|
-
* read early stays an upper bound for the rest of the transaction.
|
|
2033
|
+
* Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. Writes that
|
|
2034
|
+
* only lower the count take no lock, so an early count stays an upper bound.
|
|
2113
2035
|
*/
|
|
2114
2036
|
async function tenantLimitReached(tx, tenantRef, kind) {
|
|
2115
2037
|
const limit = configuredTenantLimits[kind];
|
|
@@ -2119,10 +2041,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2119
2041
|
await lockTenantLimit(tx, tenantRef, kind);
|
|
2120
2042
|
if ((await tenantLimitCount(tx, tenantRef, kind, limit)) < limit)
|
|
2121
2043
|
return null;
|
|
2122
|
-
//
|
|
2123
|
-
//
|
|
2124
|
-
// is read again. Counting expired rows until then keeps what a tenant stores within the limit
|
|
2125
|
-
// between maintenance passes, however short its holds.
|
|
2044
|
+
// At the limit, expired holds and their answers are deleted, as maintenance would, and the
|
|
2045
|
+
// count is read again.
|
|
2126
2046
|
if (kind !== 'holds' ||
|
|
2127
2047
|
(await deleteExpiredHolds(tx, tenantRef, limit)) +
|
|
2128
2048
|
(await forgetExpiredHoldAnswers(tx, tenantRef, limit)) ===
|
|
@@ -2133,7 +2053,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2133
2053
|
}
|
|
2134
2054
|
/**
|
|
2135
2055
|
* The tenant whose limit a write on the resource counts against: the caller's, else the
|
|
2136
|
-
* resource's (
|
|
2056
|
+
* resource's (never changes, so read before any lock). Null with no limit.
|
|
2137
2057
|
*/
|
|
2138
2058
|
async function limitTenant(tx, kind, resourceId, tenantRef) {
|
|
2139
2059
|
if (configuredTenantLimits[kind] === undefined)
|
|
@@ -2147,12 +2067,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2147
2067
|
const store = {
|
|
2148
2068
|
...slotlockEventFeedMethods(sql, retentionDays),
|
|
2149
2069
|
async applySchema(migrationOptions) {
|
|
2150
|
-
//
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
// crashes. A schema this release already left as it is changes nothing and locks no table,
|
|
2154
|
-
// unless old rows kept one of its constraints NOT VALID (then only the validation runs again)
|
|
2155
|
-
// or the rename of earlier coverage changes is not done (then only a pass of it runs).
|
|
2070
|
+
// IF NOT EXISTS DDL is idempotent but not race-free: two fresh instances can collide on
|
|
2071
|
+
// pg_namespace/pg_extension indexes, so inMigration serializes bootstrap. An unchanged schema
|
|
2072
|
+
// locks no table unless a constraint is still NOT VALID or the coverage rename is unfinished.
|
|
2156
2073
|
const next = await inMigration(sql, migrationOptions, async (tx) => {
|
|
2157
2074
|
await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
|
|
2158
2075
|
throw schemaControlRefusal(error);
|
|
@@ -2168,18 +2085,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2168
2085
|
});
|
|
2169
2086
|
return { validate: true, rename: await coverageChangesRenamePending(tx) };
|
|
2170
2087
|
});
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
// (renameEarlierCoverageChanges); the feed serves any not renamed yet redacted (event-feed.ts,
|
|
2174
|
-
// slotlockServedChange).
|
|
2088
|
+
// Renamed in short batches after the structure commits, never under its locks; the feed
|
|
2089
|
+
// redacts any not yet renamed (event-feed.ts, slotlockServedChange).
|
|
2175
2090
|
if (next.rename)
|
|
2176
2091
|
await renameEarlierCoverageChanges(sql, migrationOptions);
|
|
2177
2092
|
if (!next.validate)
|
|
2178
2093
|
return;
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
// migration stopped before them runs whole again.
|
|
2094
|
+
// After the DDL commits its ACCESS EXCLUSIVE locks are gone, so validation blocks no serving
|
|
2095
|
+
// query (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Digests go last, so a migration stopped
|
|
2096
|
+
// earlier runs whole again.
|
|
2183
2097
|
await inMigration(sql, migrationOptions, async (tx) => {
|
|
2184
2098
|
await tx.unsafe(SLOTLOCK_VALIDATION_DDL);
|
|
2185
2099
|
await tx `
|
|
@@ -2349,8 +2263,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2349
2263
|
throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
|
|
2350
2264
|
}
|
|
2351
2265
|
const release = await tenantSlots?.(tenantRef, options?.signal);
|
|
2352
|
-
// A lock
|
|
2353
|
-
//
|
|
2266
|
+
// A lock timeout leaves the transaction unusable, so the reset below would fail with another
|
|
2267
|
+
// error: noted here.
|
|
2354
2268
|
let timedOut = false;
|
|
2355
2269
|
return inTransaction(sql, async (tx) => {
|
|
2356
2270
|
if (lockTimeout !== undefined) {
|
|
@@ -2358,9 +2272,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2358
2272
|
}
|
|
2359
2273
|
if (isolation !== undefined)
|
|
2360
2274
|
await assertReadCommitted(tx);
|
|
2361
|
-
// Held shared until the transaction ends: suspension and erasure
|
|
2362
|
-
//
|
|
2363
|
-
// (READ COMMITTED: a statement after the lock sees what committed before it was granted).
|
|
2275
|
+
// Held shared until the transaction ends: suspension and erasure take it exclusively, so
|
|
2276
|
+
// they wait for this transaction, and one starting after them sees them (READ COMMITTED).
|
|
2364
2277
|
const previous = await tx `
|
|
2365
2278
|
SELECT current_setting(${configuredTenantSetting}, true) AS value,
|
|
2366
2279
|
current_setting('slotlock.actor', true) AS actor,
|
|
@@ -2379,9 +2292,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2379
2292
|
slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
|
|
2380
2293
|
throw suspensionCheckRefusal(error);
|
|
2381
2294
|
});
|
|
2382
|
-
// A caller's REPEATABLE READ or SERIALIZABLE snapshot
|
|
2383
|
-
//
|
|
2384
|
-
// its writes are refused, and it may read.
|
|
2295
|
+
// A caller's REPEATABLE READ or SERIALIZABLE snapshot predates the lock, so it may miss a
|
|
2296
|
+
// suspension: its writes are refused, reads allowed.
|
|
2385
2297
|
const refusal = context?.suspended === true
|
|
2386
2298
|
? tenantSuspendedError
|
|
2387
2299
|
: previous[0]?.isolation !== 'read committed'
|
|
@@ -2436,7 +2348,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2436
2348
|
// An object, so the key never equals a [tenantRef, externalRef] identity lock's.
|
|
2437
2349
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ resourceCount: tenantRef })}, 0))`;
|
|
2438
2350
|
// READ COMMITTED: each statement below sees every create committed before the lock.
|
|
2439
|
-
// Re-creating a
|
|
2351
|
+
// Re-creating a known reference adds none.
|
|
2440
2352
|
const known = externalRef === null
|
|
2441
2353
|
? []
|
|
2442
2354
|
: await tx `
|
|
@@ -2539,8 +2451,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2539
2451
|
const tenantRef = params.tenantRef;
|
|
2540
2452
|
return inTransaction(sql, async (tx) => {
|
|
2541
2453
|
if (pool !== '') {
|
|
2542
|
-
// Counted under the tenant's lock,
|
|
2543
|
-
//
|
|
2454
|
+
// Counted under the tenant's lock, which only READ COMMITTED makes see every committed
|
|
2455
|
+
// row.
|
|
2544
2456
|
await assertReadCommitted(tx);
|
|
2545
2457
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
|
|
2546
2458
|
const [counted] = await tx `
|
|
@@ -2668,8 +2580,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2668
2580
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2669
2581
|
throw invalidIdentityError('Slotlock resource identity is invalid');
|
|
2670
2582
|
}
|
|
2671
|
-
//
|
|
2672
|
-
//
|
|
2583
|
+
// Clock time, not transaction start: a booking that committed holding the row's share lock
|
|
2584
|
+
// went in before this.
|
|
2673
2585
|
const [row] = await sql `
|
|
2674
2586
|
UPDATE slotlock.resources
|
|
2675
2587
|
SET archived_at = COALESCE(archived_at, clock_timestamp())
|
|
@@ -2715,8 +2627,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2715
2627
|
) AS live`;
|
|
2716
2628
|
if (live?.live)
|
|
2717
2629
|
return { ok: false, code: 'resource_in_use' };
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
2630
|
+
// Reservations, events and their children, coverage and hold answers all cascade from the
|
|
2631
|
+
// resource.
|
|
2720
2632
|
await tx `DELETE FROM slotlock.resources WHERE id = ${params.id}`;
|
|
2721
2633
|
return { ok: true, deleted: true };
|
|
2722
2634
|
});
|
|
@@ -2730,10 +2642,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2730
2642
|
if (params.tenantRef !== undefined && !isValidIdentity(params.tenantRef)) {
|
|
2731
2643
|
return { ok: false, code: 'resource_not_found' };
|
|
2732
2644
|
}
|
|
2733
|
-
// A booking on a tenant's resource takes the tenant and its own id as reference
|
|
2734
|
-
//
|
|
2645
|
+
// A booking on a tenant's resource takes the tenant and its own id as reference; one on a
|
|
2646
|
+
// tenantless resource stays tenantless.
|
|
2735
2647
|
const id = randomUUID();
|
|
2736
|
-
// The buffers written, for the overlap answer if the write loses.
|
|
2737
2648
|
let bufferBeforeMs = 0;
|
|
2738
2649
|
let bufferAfterMs = 0;
|
|
2739
2650
|
for (let attempt = 1;; attempt++) {
|
|
@@ -2788,15 +2699,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2788
2699
|
catch (err) {
|
|
2789
2700
|
const code = pgCode(err);
|
|
2790
2701
|
if ((code === DEADLOCK_DETECTED || code === SERIALIZATION_FAILURE) && attempt === 1) {
|
|
2791
|
-
continue; // fully rolled back
|
|
2702
|
+
continue; // fully rolled back: one retry wins or loses with a clean 23P01
|
|
2792
2703
|
}
|
|
2793
2704
|
if (code !== EXCLUSION_VIOLATION &&
|
|
2794
2705
|
code !== DEADLOCK_DETECTED &&
|
|
2795
2706
|
code !== SERIALIZATION_FAILURE) {
|
|
2796
2707
|
throw err;
|
|
2797
2708
|
}
|
|
2798
|
-
// A
|
|
2799
|
-
//
|
|
2709
|
+
// A second contention abort is a structured loss too (possibly with a null winner), never
|
|
2710
|
+
// a raw throw.
|
|
2800
2711
|
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2801
2712
|
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2802
2713
|
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
|
|
@@ -2821,8 +2732,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2821
2732
|
if (cancelled[0])
|
|
2822
2733
|
return { ok: false, code: 'reservation_cancelled' };
|
|
2823
2734
|
// A calendar event's occupation carries a reference in this namespace too, but it is
|
|
2824
|
-
// not a booking: create, reschedule and cancel
|
|
2825
|
-
//
|
|
2735
|
+
// not a booking: create, reschedule and cancel treat it as a reference no booking
|
|
2736
|
+
// holds.
|
|
2826
2737
|
const existing = await tx `
|
|
2827
2738
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2828
2739
|
buffer_after_ms, revision, source
|
|
@@ -2833,7 +2744,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2833
2744
|
const found = existing[0];
|
|
2834
2745
|
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
2835
2746
|
if (found) {
|
|
2836
|
-
//
|
|
2747
|
+
// Moved on since: a delayed create is stale, as a delayed move is.
|
|
2837
2748
|
if (params.revision < Number(found.revision)) {
|
|
2838
2749
|
return { ok: false, code: 'stale_revision' };
|
|
2839
2750
|
}
|
|
@@ -3034,8 +2945,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3034
2945
|
}
|
|
3035
2946
|
}
|
|
3036
2947
|
if (!found) {
|
|
3037
|
-
//
|
|
3038
|
-
//
|
|
2948
|
+
// This reference is a calendar event's occupation, not a booking: nothing to cancel or
|
|
2949
|
+
// retire.
|
|
3039
2950
|
const [occupation] = await tx `
|
|
3040
2951
|
SELECT id FROM slotlock.reservations
|
|
3041
2952
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
@@ -3049,7 +2960,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3049
2960
|
return { ok: false, code: 'reservation_limit_reached', limit };
|
|
3050
2961
|
}
|
|
3051
2962
|
// A reference no booking holds yet is retired too, so a create delivered after its cancel
|
|
3052
|
-
// cannot bring
|
|
2963
|
+
// cannot bring it back.
|
|
3053
2964
|
await tx `
|
|
3054
2965
|
INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
|
|
3055
2966
|
VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
|
|
@@ -3064,9 +2975,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3064
2975
|
if (!isFiniteWindow({ start: params.start, end: params.end })) {
|
|
3065
2976
|
return { ok: false, code: 'invalid_window' };
|
|
3066
2977
|
}
|
|
3067
|
-
//
|
|
3068
|
-
//
|
|
3069
|
-
// (Invalid Date back to the caller). All refused pre-table.
|
|
2978
|
+
// NaN/Infinity would raise a raw 22008 in make_interval, and a huge ttl could exceed the JS
|
|
2979
|
+
// Date range.
|
|
3070
2980
|
if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
|
|
3071
2981
|
return { ok: false, code: 'invalid_ttl' };
|
|
3072
2982
|
}
|
|
@@ -3106,8 +3016,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3106
3016
|
...(holderRef !== undefined ? { holderRef } : {}),
|
|
3107
3017
|
});
|
|
3108
3018
|
const ttlSeconds = params.ttlMs / 1000;
|
|
3109
|
-
// The
|
|
3110
|
-
// leading buffer.
|
|
3019
|
+
// The buffer asked for, raised to the resource's turnaround, and its rule's leading buffer.
|
|
3111
3020
|
let written = bufferAfterMs;
|
|
3112
3021
|
let writtenBefore = 0;
|
|
3113
3022
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3145,11 +3054,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3145
3054
|
return { ok: false, code: 'resource_archived' };
|
|
3146
3055
|
if (holdLimit !== null)
|
|
3147
3056
|
return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
|
|
3148
|
-
// Seconds the holder already holds, turnaround included, when its time is capped.
|
|
3149
3057
|
let heldSeconds = null;
|
|
3150
3058
|
if (holderRef !== undefined) {
|
|
3151
|
-
//
|
|
3152
|
-
//
|
|
3059
|
+
// Counted under a lock on the holder, so racing holds stop at the cap: an agent
|
|
3060
|
+
// cannot hold a resource's whole week.
|
|
3153
3061
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
|
|
3154
3062
|
'slotlock-holder',
|
|
3155
3063
|
tenantRef,
|
|
@@ -3229,8 +3137,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3229
3137
|
code !== SERIALIZATION_FAILURE) {
|
|
3230
3138
|
throw err;
|
|
3231
3139
|
}
|
|
3232
|
-
// A
|
|
3233
|
-
//
|
|
3140
|
+
// A second contention abort is a structured loss too (possibly with a null winner), never
|
|
3141
|
+
// a raw throw.
|
|
3234
3142
|
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
3235
3143
|
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
3236
3144
|
return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
|
|
@@ -3344,13 +3252,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3344
3252
|
},
|
|
3345
3253
|
async confirmHold(holdId, options = {}) {
|
|
3346
3254
|
const tenantRef = holdTenantRef(options);
|
|
3347
|
-
// Not an id the store issues
|
|
3255
|
+
// Not an id the store issues (and a raw 22P02 in the query).
|
|
3348
3256
|
if (!isValidResourceId(holdId))
|
|
3349
3257
|
return { ok: false, code: 'hold_not_found' };
|
|
3350
3258
|
return inTransaction(sql, async (tx) => {
|
|
3351
|
-
//
|
|
3352
|
-
//
|
|
3353
|
-
// every booking write takes them.
|
|
3259
|
+
// A hold never changes resource, so the owner is read before the identity lock, taken
|
|
3260
|
+
// before the row lock as every booking write does.
|
|
3354
3261
|
const [owner] = await tx `
|
|
3355
3262
|
SELECT hold.resource_id, resource.tenant_ref AS resource_tenant
|
|
3356
3263
|
FROM slotlock.reservations hold
|
|
@@ -3364,7 +3271,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3364
3271
|
await lockExternalIdentity(tx, bookingTenant, holdId);
|
|
3365
3272
|
const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
|
|
3366
3273
|
// The resource's share lock, as every booking write takes it: an archive waits for this
|
|
3367
|
-
// confirmation
|
|
3274
|
+
// confirmation.
|
|
3368
3275
|
const [resource] = await tx `
|
|
3369
3276
|
SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
3370
3277
|
WHERE id = ${owner.resource_id}
|
|
@@ -3376,16 +3283,13 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3376
3283
|
WHERE id = ${holdId}
|
|
3377
3284
|
FOR UPDATE`;
|
|
3378
3285
|
if (!row) {
|
|
3379
|
-
//
|
|
3380
|
-
//
|
|
3381
|
-
// taken; both refusal codes are terminal for the caller.
|
|
3286
|
+
// A hold reaped by a rival while this confirm waited reads as hold_not_found; both
|
|
3287
|
+
// refusals are terminal for the caller.
|
|
3382
3288
|
return { ok: false, code: 'hold_not_found' };
|
|
3383
3289
|
}
|
|
3384
3290
|
if (row.status === 'confirmed') {
|
|
3385
|
-
//
|
|
3386
|
-
//
|
|
3387
|
-
// elsewhere. A confirmed hold's reference is its own id (none without a tenant); a
|
|
3388
|
-
// booking with another reference was never this hold.
|
|
3291
|
+
// A retried confirm of an already-confirmed hold is success: reporting failure would make
|
|
3292
|
+
// callers double-book elsewhere. A booking with another reference was never this hold.
|
|
3389
3293
|
if (row.external_ref !== null && row.external_ref !== row.id) {
|
|
3390
3294
|
return { ok: false, code: 'hold_not_found' };
|
|
3391
3295
|
}
|
|
@@ -3393,7 +3297,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3393
3297
|
}
|
|
3394
3298
|
if (!row.live)
|
|
3395
3299
|
return { ok: false, code: 'hold_expired' };
|
|
3396
|
-
// Archived since the hold was taken: a confirmation would be a new booking on it.
|
|
3397
3300
|
if (resource?.archived !== false)
|
|
3398
3301
|
return { ok: false, code: 'resource_archived' };
|
|
3399
3302
|
if (bookingTenant !== null) {
|
|
@@ -3412,8 +3315,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3412
3315
|
if (bookingLimit !== null) {
|
|
3413
3316
|
return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
|
|
3414
3317
|
}
|
|
3415
|
-
// The
|
|
3416
|
-
//
|
|
3318
|
+
// The trigger already set the occupied end; the tenant and reference make the hold a
|
|
3319
|
+
// booking cancel and reschedule reach.
|
|
3417
3320
|
const [confirmed] = await tx `
|
|
3418
3321
|
UPDATE slotlock.reservations
|
|
3419
3322
|
SET status = 'confirmed', expires_at = NULL,
|
|
@@ -3482,8 +3385,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3482
3385
|
});
|
|
3483
3386
|
}
|
|
3484
3387
|
}
|
|
3485
|
-
// Expired holds are
|
|
3486
|
-
//
|
|
3388
|
+
// Expired holds are excluded so findNextAvailable can offer their space; the acquiring
|
|
3389
|
+
// transaction reaps the row itself.
|
|
3487
3390
|
const rows = await sql `
|
|
3488
3391
|
SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
|
|
3489
3392
|
COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
|
|
@@ -3532,8 +3435,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3532
3435
|
});
|
|
3533
3436
|
}
|
|
3534
3437
|
catch (error) {
|
|
3535
|
-
// A long-running series can
|
|
3536
|
-
//
|
|
3438
|
+
// A long-running series can outgrow the write's search budget from its start; a stored
|
|
3439
|
+
// one is expanded below from its last cursor.
|
|
3537
3440
|
if (!(error instanceof CalendarExpansionLimitError) || !event.recurrence)
|
|
3538
3441
|
throw error;
|
|
3539
3442
|
}
|
|
@@ -3552,7 +3455,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3552
3455
|
});
|
|
3553
3456
|
const recurrenceEndsBy = recurrenceEndsByColumn(event);
|
|
3554
3457
|
let attempted = expanded?.occurrences ?? [];
|
|
3555
|
-
// The buffers the attempt kept, for the overlap answer if it loses.
|
|
3556
3458
|
let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
|
|
3557
3459
|
let storedEventId;
|
|
3558
3460
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3580,8 +3482,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3580
3482
|
if (ownerRef !== SLOTLOCK_INTERNAL_EVENT_OWNER_REF) {
|
|
3581
3483
|
await lockCalendarOwnerQuota(tx, params.tenantRef, ownerRef);
|
|
3582
3484
|
const commandCount = await calendarOwnerCommandCount(tx, params.tenantRef, ownerRef);
|
|
3583
|
-
//
|
|
3584
|
-
//
|
|
3485
|
+
// Reserve one cancellation slot for every possible live identity, so update churn
|
|
3486
|
+
// cannot leave a principal unable to release calendar space.
|
|
3585
3487
|
const putCommandLimit = configuredAgentOwnerCommandQuota - configuredAgentOwnerEventQuota;
|
|
3586
3488
|
if (commandCount >= putCommandLimit) {
|
|
3587
3489
|
return {
|
|
@@ -3682,7 +3584,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3682
3584
|
only.end > hold.ends_at) {
|
|
3683
3585
|
return { ok: false, code: 'hold_mismatch' };
|
|
3684
3586
|
}
|
|
3685
|
-
// The hold and its alternatives give way to the event in this transaction.
|
|
3686
3587
|
await tx `
|
|
3687
3588
|
DELETE FROM slotlock.reservations
|
|
3688
3589
|
WHERE status = 'held' AND holder_ref = ${holderRef}
|
|
@@ -3773,8 +3674,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3773
3674
|
${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
|
|
3774
3675
|
)`;
|
|
3775
3676
|
}
|
|
3776
|
-
// The normalized children are the
|
|
3777
|
-
//
|
|
3677
|
+
// The normalized children are canonical; the JSON columns above are a compatibility
|
|
3678
|
+
// mirror until older readers retire.
|
|
3778
3679
|
await replaceCalendarEventChildren(tx, params.tenantRef, eventId, event);
|
|
3779
3680
|
await replaceCalendarEventOccurrences(tx, {
|
|
3780
3681
|
tenantRef: params.tenantRef,
|
|
@@ -3945,11 +3846,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3945
3846
|
});
|
|
3946
3847
|
}
|
|
3947
3848
|
const after = rollCursor(params.after);
|
|
3948
|
-
// Due
|
|
3949
|
-
//
|
|
3950
|
-
// first: however many it cannot extend, they never stand in front of one it has not tried,
|
|
3951
|
-
// so every pass that is not capped by such new ones reaches the healthy series, and the ones
|
|
3952
|
-
// put back take turns.
|
|
3849
|
+
// Due masters, never-deferred ones first, then deferred ones longest-waiting first: masters
|
|
3850
|
+
// it cannot extend never starve healthy ones, and deferred ones take turns.
|
|
3953
3851
|
const candidates = await sql `
|
|
3954
3852
|
SELECT id, materialized_ends_at, roll_retry_at
|
|
3955
3853
|
FROM slotlock.calendar_events
|
|
@@ -3986,10 +3884,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3986
3884
|
}
|
|
3987
3885
|
catch (error) {
|
|
3988
3886
|
if (error instanceof CalendarExpansionLimitError) {
|
|
3989
|
-
//
|
|
3990
|
-
//
|
|
3991
|
-
// earlier Slotlock stored that this one refuses. Nothing was written; its old coverage
|
|
3992
|
-
// stays, and the masters after it still roll.
|
|
3887
|
+
// Over the expansion budget (or a rule this release refuses): nothing written, old
|
|
3888
|
+
// coverage kept, later masters still roll.
|
|
3993
3889
|
refused += 1;
|
|
3994
3890
|
refusedEventIds.push(candidate.id);
|
|
3995
3891
|
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
@@ -4002,16 +3898,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4002
3898
|
if (code === EXCLUSION_VIOLATION ||
|
|
4003
3899
|
code === DEADLOCK_DETECTED ||
|
|
4004
3900
|
code === SERIALIZATION_FAILURE) {
|
|
4005
|
-
// The savepoint rolled
|
|
4006
|
-
//
|
|
3901
|
+
// The savepoint rolled everything back, so old coverage stays truthful and callers
|
|
3902
|
+
// fail closed beyond it.
|
|
4007
3903
|
conflicts += 1;
|
|
4008
3904
|
conflictEventIds.push(candidate.id);
|
|
4009
3905
|
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
4010
3906
|
break;
|
|
4011
3907
|
}
|
|
4012
|
-
//
|
|
4013
|
-
//
|
|
4014
|
-
// budget, so no single row stops the tenant's roll.
|
|
3908
|
+
// Any other failure was rolled back with its savepoint too: defer it like an
|
|
3909
|
+
// over-budget one, so no single row stops the tenant's roll.
|
|
4015
3910
|
refused += 1;
|
|
4016
3911
|
refusedEventIds.push(candidate.id);
|
|
4017
3912
|
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
@@ -4178,8 +4073,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4178
4073
|
idempotent: true,
|
|
4179
4074
|
};
|
|
4180
4075
|
}
|
|
4181
|
-
//
|
|
4182
|
-
//
|
|
4076
|
+
// Owner and reference never change, so they are read before the identity lock, taken before
|
|
4077
|
+
// the row lock as every event write does.
|
|
4183
4078
|
const [identity] = await tx `
|
|
4184
4079
|
SELECT owner_ref, external_ref FROM slotlock.calendar_events
|
|
4185
4080
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
|
|
@@ -4190,7 +4085,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4190
4085
|
SELECT id, resource_id, revision FROM slotlock.calendar_events
|
|
4191
4086
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
|
|
4192
4087
|
FOR UPDATE`;
|
|
4193
|
-
// Cancelled while this waited on its lock.
|
|
4194
4088
|
if (!found)
|
|
4195
4089
|
return { ok: false, code: 'event_not_found' };
|
|
4196
4090
|
if (params.expectedResourceId !== undefined &&
|
|
@@ -4290,7 +4184,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4290
4184
|
catch (error) {
|
|
4291
4185
|
if (pgCode(error) !== EXCLUSION_VIOLATION)
|
|
4292
4186
|
throw error;
|
|
4293
|
-
// It stays where it was; whoever owns it decides.
|
|
4294
4187
|
await report(event, 'event.timezone_conflict');
|
|
4295
4188
|
conflicts++;
|
|
4296
4189
|
}
|
|
@@ -4307,8 +4200,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4307
4200
|
code: 'invalid_limit',
|
|
4308
4201
|
});
|
|
4309
4202
|
}
|
|
4310
|
-
// Never under the default: a shorter retention trims the feed, not how long a
|
|
4311
|
-
//
|
|
4203
|
+
// Never under the default: a shorter retention trims the feed, not how long a retry is
|
|
4204
|
+
// answered as the first was.
|
|
4312
4205
|
const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
|
|
4313
4206
|
if (!Number.isSafeInteger(keepDays) ||
|
|
4314
4207
|
keepDays < 1 ||
|
|
@@ -4318,11 +4211,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4318
4211
|
});
|
|
4319
4212
|
}
|
|
4320
4213
|
return inTransaction(sql, async (tx) => {
|
|
4321
|
-
// A 0.2 server still
|
|
4322
|
-
// createReservation bookings
|
|
4323
|
-
//
|
|
4324
|
-
//
|
|
4325
|
-
// first, as every booking write does; a tombstone of its id retired nothing and goes.
|
|
4214
|
+
// A 0.2 server still running after the migration wrote confirmed holds and
|
|
4215
|
+
// createReservation bookings without the tenant or a reference; give them both, as the
|
|
4216
|
+
// migration does. Each takes its reference's identity lock first, and a tombstone of its id
|
|
4217
|
+
// goes.
|
|
4326
4218
|
const legacy = await tx `
|
|
4327
4219
|
SELECT booking.id
|
|
4328
4220
|
FROM slotlock.reservations booking
|
|
@@ -4402,8 +4294,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4402
4294
|
code: 'invalid_limit',
|
|
4403
4295
|
});
|
|
4404
4296
|
}
|
|
4405
|
-
// Never under the default: a shorter retention trims the feed, not how long a
|
|
4406
|
-
//
|
|
4297
|
+
// Never under the default: a shorter retention trims the feed, not how long a retry is
|
|
4298
|
+
// answered as the first was.
|
|
4407
4299
|
const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
|
|
4408
4300
|
if (!Number.isSafeInteger(keepDays) ||
|
|
4409
4301
|
keepDays < 1 ||
|
|
@@ -4413,8 +4305,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4413
4305
|
});
|
|
4414
4306
|
}
|
|
4415
4307
|
return inTransaction(sql, async (tx) => {
|
|
4416
|
-
// Commands are
|
|
4417
|
-
// owner's
|
|
4308
|
+
// Commands are replay evidence, never resurrection authority: after the window every
|
|
4309
|
+
// owner's go.
|
|
4418
4310
|
const commands = await tx `
|
|
4419
4311
|
WITH candidates AS MATERIALIZED (
|
|
4420
4312
|
SELECT command.owner_ref, command.idempotency_key
|
|
@@ -4434,9 +4326,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4434
4326
|
RETURNING 1
|
|
4435
4327
|
)
|
|
4436
4328
|
SELECT count(*)::int AS deleted FROM deleted`;
|
|
4437
|
-
//
|
|
4438
|
-
//
|
|
4439
|
-
// row, so a row they hold is skipped and the delete repeats the selection predicate.
|
|
4329
|
+
// An agent-owned tombstone may follow once all its commands are gone; internal ones are
|
|
4330
|
+
// provider/sync authority and stay. Locked rows are skipped and the predicate rechecked.
|
|
4440
4331
|
const tombstones = await tx `
|
|
4441
4332
|
WITH candidates AS MATERIALIZED (
|
|
4442
4333
|
SELECT tombstone.owner_ref, tombstone.external_ref
|
|
@@ -4532,7 +4423,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4532
4423
|
if (params.revision !== currentRevision + 1) {
|
|
4533
4424
|
return { ok: false, code: 'revision_conflict' };
|
|
4534
4425
|
}
|
|
4535
|
-
// A fresh read ends a staleness: the next one is reported again.
|
|
4536
4426
|
await tx `
|
|
4537
4427
|
UPDATE slotlock.calendar_coverage
|
|
4538
4428
|
SET starts_at = ${window.start}, ends_at = ${window.end},
|
|
@@ -4686,7 +4576,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4686
4576
|
if (refusal)
|
|
4687
4577
|
return 0;
|
|
4688
4578
|
const tenantRef = params.tenantRef;
|
|
4689
|
-
// The same test twice: to find them, then under each reference's lock
|
|
4579
|
+
// The same test twice: to find them, then under each reference's lock.
|
|
4690
4580
|
const due = (tx, externalRef) => tx `
|
|
4691
4581
|
SELECT reservation.id, reservation.external_ref, reservation.revision, reservation.resource_id,
|
|
4692
4582
|
(resource.booking_rules->>'checkInMinutes')::int AS minutes
|
|
@@ -4771,7 +4661,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4771
4661
|
if (!member)
|
|
4772
4662
|
return { ok: false, code: 'pool_not_found' };
|
|
4773
4663
|
}
|
|
4774
|
-
// Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at
|
|
4664
|
+
// Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at
|
|
4665
|
+
// the cap.
|
|
4775
4666
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
|
|
4776
4667
|
const [counts] = await tx `
|
|
4777
4668
|
SELECT count(*)::int AS tenant, count(*) FILTER (WHERE holder_ref = ${holderRef})::int AS holder
|
|
@@ -4812,7 +4703,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4812
4703
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
|
|
4813
4704
|
return false;
|
|
4814
4705
|
return inTransaction(sql, async (tx) => {
|
|
4815
|
-
// Waits for an offer pass that has this entry locked, so it leaves what that pass decided.
|
|
4816
4706
|
const [left] = await tx `
|
|
4817
4707
|
DELETE FROM slotlock.waitlist_entries
|
|
4818
4708
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
@@ -4820,8 +4710,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4820
4710
|
RETURNING hold_id`;
|
|
4821
4711
|
if (!left)
|
|
4822
4712
|
return false;
|
|
4823
|
-
// An offer
|
|
4824
|
-
// one is a booking now and stays.
|
|
4713
|
+
// An untaken offer's hold goes too; a confirmed one is a booking now and stays.
|
|
4825
4714
|
if (left.hold_id) {
|
|
4826
4715
|
await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
|
|
4827
4716
|
}
|
|
@@ -5020,7 +4909,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5020
4909
|
WHERE tenant_ref = ${tenantRef} AND status <> 'waiting'
|
|
5021
4910
|
AND ends_at < statement_timestamp() - make_interval(days => ${retentionDays})`;
|
|
5022
4911
|
}
|
|
5023
|
-
// One page in join order; the caller passes `next` back for the rest.
|
|
5024
4912
|
const waiting = await tx `
|
|
5025
4913
|
SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
|
|
5026
4914
|
WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
|
|
@@ -5028,19 +4916,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5028
4916
|
OR (created_at, id) > (${after?.createdAt ?? null}::timestamptz, ${after?.id ?? null}::uuid))
|
|
5029
4917
|
ORDER BY created_at, id
|
|
5030
4918
|
LIMIT ${limit}`;
|
|
5031
|
-
// Holds made through a store on this transaction, each in a savepoint of its own.
|
|
5032
4919
|
const inner = buildSlotlockStore(tx, options, null);
|
|
5033
4920
|
let offered = 0;
|
|
5034
4921
|
for (const entry of waiting) {
|
|
5035
|
-
// Locked so a leave waits for this offer, and skipped if it left first.
|
|
5036
4922
|
const [still] = await tx `
|
|
5037
4923
|
SELECT 1 FROM slotlock.waitlist_entries
|
|
5038
4924
|
WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
|
|
5039
4925
|
if (!still)
|
|
5040
4926
|
continue;
|
|
5041
|
-
// Only resources
|
|
5042
|
-
// until the transaction ends
|
|
5043
|
-
// A hold there keeps the resource's buffers (its rules as they apply), so they must be free too.
|
|
4927
|
+
// Only resources that look free (buffers included) are tried: a refused hold keeps its
|
|
4928
|
+
// locks until the transaction ends.
|
|
5044
4929
|
const candidates = await tx `
|
|
5045
4930
|
SELECT resources.id
|
|
5046
4931
|
FROM slotlock.resources,
|
|
@@ -5136,7 +5021,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5136
5021
|
code: 'resource_tenant_mismatch',
|
|
5137
5022
|
});
|
|
5138
5023
|
}
|
|
5139
|
-
// Each row's occupied time: its buffers are as busy as the booking.
|
|
5140
5024
|
const busyRows = await sql `
|
|
5141
5025
|
SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
|
|
5142
5026
|
COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
|
|
@@ -5187,8 +5071,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5187
5071
|
FROM recurring_sources
|
|
5188
5072
|
WHERE earliest_possible_at < ${window.end}`;
|
|
5189
5073
|
const localRecurrenceRequired = Number(localRecurrenceCoverage?.source_count ?? 0) > 0;
|
|
5190
|
-
//
|
|
5191
|
-
// writes do (bookingJudge)
|
|
5074
|
+
// Requiring certainty means every sync covering the resource, judged by the same day, as
|
|
5075
|
+
// writes do (bookingJudge).
|
|
5192
5076
|
const requireCertain = owner[0].require_certain;
|
|
5193
5077
|
const recordedSources = requireCertain
|
|
5194
5078
|
? (await sql `
|
|
@@ -5223,8 +5107,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5223
5107
|
let hasFreshOverlap = coverageRows.some((coverage) => coverage.fresh && coverage.starts_at < window.end && coverage.ends_at > window.start);
|
|
5224
5108
|
if (localRecurrenceRequired) {
|
|
5225
5109
|
if (localRecurrenceCoverage?.complete !== true) {
|
|
5226
|
-
//
|
|
5227
|
-
//
|
|
5110
|
+
// The intrinsic source first: the caller can repair it by rolling the horizon,
|
|
5111
|
+
// independent of providers.
|
|
5228
5112
|
missingSources.unshift(SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
|
|
5229
5113
|
}
|
|
5230
5114
|
hasFreshOverlap ||= localRecurrenceCoverage?.overlaps === true;
|
|
@@ -5273,8 +5157,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5273
5157
|
return null;
|
|
5274
5158
|
const resource = toResource(row);
|
|
5275
5159
|
const rules = params.rules ?? resource.availabilityRules ?? [];
|
|
5276
|
-
// Only what a booking made now may take: the resource's booking rules (as they apply) bound
|
|
5277
|
-
// the search.
|
|
5278
5160
|
const search = bookingSearch({
|
|
5279
5161
|
rules: resource.effectiveBookingRules,
|
|
5280
5162
|
timezone: resource.timezone,
|
|
@@ -5285,11 +5167,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5285
5167
|
if (!search)
|
|
5286
5168
|
return null;
|
|
5287
5169
|
const { window } = search;
|
|
5288
|
-
// A slot's buffers may run past the window:
|
|
5170
|
+
// A slot's buffers may run past the window: that reach must be proven free too.
|
|
5289
5171
|
const reach = bookingSearchReach(search);
|
|
5290
5172
|
let busy;
|
|
5291
5173
|
if (tenantRef === null) {
|
|
5292
|
-
//
|
|
5174
|
+
// A tenantless resource has no events, coverage or series: only its rows.
|
|
5293
5175
|
busy = await this.listBusy(params.resourceId, reach);
|
|
5294
5176
|
}
|
|
5295
5177
|
else {
|
|
@@ -5311,7 +5193,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5311
5193
|
? { coverageMaxAgeMs: params.coverageMaxAgeMs }
|
|
5312
5194
|
: {}),
|
|
5313
5195
|
});
|
|
5314
|
-
// Absence is never
|
|
5196
|
+
// Absence is never free time.
|
|
5315
5197
|
if (freeBusy.coverage.state !== 'complete')
|
|
5316
5198
|
return null;
|
|
5317
5199
|
busy.push(...freeBusy.busy);
|
|
@@ -5394,7 +5276,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5394
5276
|
};
|
|
5395
5277
|
return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
|
|
5396
5278
|
}
|
|
5397
|
-
/** The deployment role alone may suspend:
|
|
5279
|
+
/** The deployment role alone may suspend: others are refused EXECUTE on the functions. */
|
|
5398
5280
|
function suspensionRefusal(error) {
|
|
5399
5281
|
if (pgCode(error) !== '42501')
|
|
5400
5282
|
return error;
|