@fedify/postgres 2.4.5-dev.2453 → 2.4.5

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/dist/kv.cjs CHANGED
@@ -13,6 +13,9 @@ const logger = (0, _logtape_logtape.getLogger)([
13
13
  function quoteIdentifier(identifier) {
14
14
  return `"${identifier.replaceAll("\"", "\"\"").replaceAll(".", "\".\"")}"`;
15
15
  }
16
+ const expiryBatchSize = 1e3;
17
+ const expiryInterval = 6e4;
18
+ const expiryCatchUpInterval = 1e3;
16
19
  /**
17
20
  * A key–value store that uses PostgreSQL as the underlying storage.
18
21
  *
@@ -36,6 +39,8 @@ var PostgresKvStore = class {
36
39
  #initialized = false;
37
40
  #initializing;
38
41
  #driverSerializesJson = false;
42
+ #expiring;
43
+ #nextExpiry = 0;
39
44
  /**
40
45
  * Creates a new PostgreSQL key–value store.
41
46
  * @param sql The PostgreSQL client to use.
@@ -47,18 +52,58 @@ var PostgresKvStore = class {
47
52
  this.#unlogged = options.unlogged ?? false;
48
53
  this.#skipDdl = options.initialized ?? false;
49
54
  }
50
- async #expire() {
51
- await this.#sql`
52
- DELETE FROM ${this.#sql(this.#tableName)}
53
- WHERE ttl IS NOT NULL AND created + ttl < CURRENT_TIMESTAMP;
55
+ #expire() {
56
+ const callerOwned = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
57
+ if (this.#expiring) return callerOwned ? this.#expiring : void 0;
58
+ if (performance.now() < this.#nextExpiry) return;
59
+ this.#expiring = (async () => {
60
+ let interval = expiryInterval;
61
+ try {
62
+ if (await this.#sweep() === expiryBatchSize) interval = expiryCatchUpInterval;
63
+ } catch (error) {
64
+ if (callerOwned) throw error;
65
+ logger.warn("Failed to expire keys in {tableName}: {error}", {
66
+ tableName: this.#tableName,
67
+ error
68
+ });
69
+ } finally {
70
+ this.#nextExpiry = performance.now() + interval * (1 + Math.random());
71
+ this.#expiring = void 0;
72
+ }
73
+ })();
74
+ return callerOwned ? this.#expiring : void 0;
75
+ }
76
+ async #sweep() {
77
+ const [result] = await this.#sql`
78
+ WITH sweep_lock AS MATERIALIZED (
79
+ SELECT pg_try_advisory_xact_lock(
80
+ (hashtext('fedify.postgres.kv.expire')::bigint << 32) |
81
+ (SELECT typrelid::bigint FROM pg_catalog.pg_type
82
+ WHERE oid = pg_typeof(NULL::${this.#sql(this.#tableName)})::oid)
83
+ ) AS acquired
84
+ ), candidates AS MATERIALIZED (
85
+ SELECT tableoid AS relation, ctid AS tid
86
+ FROM ${this.#sql(this.#tableName)}
87
+ WHERE (SELECT acquired FROM sweep_lock)
88
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
89
+ LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
90
+ ), deleted AS (
91
+ DELETE FROM ${this.#sql(this.#tableName)}
92
+ WHERE ctid = ANY(ARRAY(SELECT tid FROM candidates))
93
+ AND (tableoid, ctid) IN (SELECT relation, tid FROM candidates)
94
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
95
+ RETURNING 1
96
+ )
97
+ SELECT count(*)::int AS count FROM deleted;
54
98
  `;
99
+ return result.count;
55
100
  }
56
101
  async get(key) {
57
102
  await this.initialize();
58
103
  const result = await this.#sql`
59
104
  SELECT value
60
105
  FROM ${this.#sql(this.#tableName)}
61
- WHERE key = ${key} AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP);
106
+ WHERE key = ${key} AND (ttl IS NULL OR created + ttl > statement_timestamp());
62
107
  `;
63
108
  if (result.length < 1) return void 0;
64
109
  return result[0].value;
@@ -67,16 +112,17 @@ var PostgresKvStore = class {
67
112
  await this.initialize();
68
113
  const ttl = options?.ttl == null ? null : Temporal.Duration.from(options.ttl).toString();
69
114
  await this.#sql`
70
- INSERT INTO ${this.#sql(this.#tableName)} (key, value, ttl)
115
+ INSERT INTO ${this.#sql(this.#tableName)} (key, value, created, ttl)
71
116
  VALUES (
72
117
  ${key},
73
118
  ${this.#json(value)},
119
+ statement_timestamp(),
74
120
  ${ttl}
75
121
  )
76
122
  ON CONFLICT (key)
77
123
  DO UPDATE SET
78
124
  value = EXCLUDED.value,
79
- created = CURRENT_TIMESTAMP,
125
+ created = statement_timestamp(),
80
126
  ttl = EXCLUDED.ttl;
81
127
  `;
82
128
  await this.#expire();
@@ -104,7 +150,7 @@ var PostgresKvStore = class {
104
150
  VALUES (
105
151
  ${key},
106
152
  ${this.#json(newValue)},
107
- CURRENT_TIMESTAMP,
153
+ statement_timestamp(),
108
154
  ${ttl}
109
155
  )
110
156
  ON CONFLICT (key)
@@ -113,13 +159,13 @@ var PostgresKvStore = class {
113
159
  created = EXCLUDED.created,
114
160
  ttl = EXCLUDED.ttl
115
161
  WHERE existing.ttl IS NOT NULL
116
- AND existing.created + existing.ttl <= CURRENT_TIMESTAMP
162
+ AND existing.created + existing.ttl <= statement_timestamp()
117
163
  RETURNING key;
118
164
  `;
119
165
  else if (newValue === void 0) result = await this.#sql`
120
166
  DELETE FROM ${this.#sql(this.#tableName)}
121
167
  WHERE key = ${key}
122
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
168
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
123
169
  AND value = ${this.#json(expectedValue)}
124
170
  RETURNING key;
125
171
  `;
@@ -127,10 +173,10 @@ var PostgresKvStore = class {
127
173
  UPDATE ${this.#sql(this.#tableName)}
128
174
  SET
129
175
  value = ${this.#json(newValue)},
130
- created = CURRENT_TIMESTAMP,
176
+ created = statement_timestamp(),
131
177
  ttl = ${ttl}
132
178
  WHERE key = ${key}
133
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
179
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
134
180
  AND value = ${this.#json(expectedValue)}
135
181
  RETURNING key;
136
182
  `;
@@ -147,7 +193,7 @@ var PostgresKvStore = class {
147
193
  if (prefix == null || prefix.length === 0) results = await this.#sql`
148
194
  SELECT key, value
149
195
  FROM ${this.#sql(this.#tableName)}
150
- WHERE ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP
196
+ WHERE ttl IS NULL OR created + ttl > statement_timestamp()
151
197
  ORDER BY key
152
198
  `;
153
199
  else {
@@ -157,7 +203,7 @@ var PostgresKvStore = class {
157
203
  FROM ${this.#sql(this.#tableName)}
158
204
  WHERE array_length(key, 1) >= ${prefixLength}
159
205
  AND key[1:${prefixLength}] = ${prefix}::text[]
160
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
206
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
161
207
  ORDER BY key
162
208
  `;
163
209
  }
@@ -218,6 +264,7 @@ var PostgresKvStore = class {
218
264
  * does not exist.
219
265
  */
220
266
  async drop() {
267
+ await this.#expiring;
221
268
  await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
222
269
  }
223
270
  #json(value) {
package/dist/kv.js CHANGED
@@ -10,6 +10,9 @@ const logger = getLogger([
10
10
  function quoteIdentifier(identifier) {
11
11
  return `"${identifier.replaceAll("\"", "\"\"").replaceAll(".", "\".\"")}"`;
12
12
  }
13
+ const expiryBatchSize = 1e3;
14
+ const expiryInterval = 6e4;
15
+ const expiryCatchUpInterval = 1e3;
13
16
  /**
14
17
  * A key–value store that uses PostgreSQL as the underlying storage.
15
18
  *
@@ -33,6 +36,8 @@ var PostgresKvStore = class {
33
36
  #initialized = false;
34
37
  #initializing;
35
38
  #driverSerializesJson = false;
39
+ #expiring;
40
+ #nextExpiry = 0;
36
41
  /**
37
42
  * Creates a new PostgreSQL key–value store.
38
43
  * @param sql The PostgreSQL client to use.
@@ -44,18 +49,58 @@ var PostgresKvStore = class {
44
49
  this.#unlogged = options.unlogged ?? false;
45
50
  this.#skipDdl = options.initialized ?? false;
46
51
  }
47
- async #expire() {
48
- await this.#sql`
49
- DELETE FROM ${this.#sql(this.#tableName)}
50
- WHERE ttl IS NOT NULL AND created + ttl < CURRENT_TIMESTAMP;
52
+ #expire() {
53
+ const callerOwned = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
54
+ if (this.#expiring) return callerOwned ? this.#expiring : void 0;
55
+ if (performance.now() < this.#nextExpiry) return;
56
+ this.#expiring = (async () => {
57
+ let interval = expiryInterval;
58
+ try {
59
+ if (await this.#sweep() === expiryBatchSize) interval = expiryCatchUpInterval;
60
+ } catch (error) {
61
+ if (callerOwned) throw error;
62
+ logger.warn("Failed to expire keys in {tableName}: {error}", {
63
+ tableName: this.#tableName,
64
+ error
65
+ });
66
+ } finally {
67
+ this.#nextExpiry = performance.now() + interval * (1 + Math.random());
68
+ this.#expiring = void 0;
69
+ }
70
+ })();
71
+ return callerOwned ? this.#expiring : void 0;
72
+ }
73
+ async #sweep() {
74
+ const [result] = await this.#sql`
75
+ WITH sweep_lock AS MATERIALIZED (
76
+ SELECT pg_try_advisory_xact_lock(
77
+ (hashtext('fedify.postgres.kv.expire')::bigint << 32) |
78
+ (SELECT typrelid::bigint FROM pg_catalog.pg_type
79
+ WHERE oid = pg_typeof(NULL::${this.#sql(this.#tableName)})::oid)
80
+ ) AS acquired
81
+ ), candidates AS MATERIALIZED (
82
+ SELECT tableoid AS relation, ctid AS tid
83
+ FROM ${this.#sql(this.#tableName)}
84
+ WHERE (SELECT acquired FROM sweep_lock)
85
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
86
+ LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
87
+ ), deleted AS (
88
+ DELETE FROM ${this.#sql(this.#tableName)}
89
+ WHERE ctid = ANY(ARRAY(SELECT tid FROM candidates))
90
+ AND (tableoid, ctid) IN (SELECT relation, tid FROM candidates)
91
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
92
+ RETURNING 1
93
+ )
94
+ SELECT count(*)::int AS count FROM deleted;
51
95
  `;
96
+ return result.count;
52
97
  }
53
98
  async get(key) {
54
99
  await this.initialize();
55
100
  const result = await this.#sql`
56
101
  SELECT value
57
102
  FROM ${this.#sql(this.#tableName)}
58
- WHERE key = ${key} AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP);
103
+ WHERE key = ${key} AND (ttl IS NULL OR created + ttl > statement_timestamp());
59
104
  `;
60
105
  if (result.length < 1) return void 0;
61
106
  return result[0].value;
@@ -64,16 +109,17 @@ var PostgresKvStore = class {
64
109
  await this.initialize();
65
110
  const ttl = options?.ttl == null ? null : Temporal.Duration.from(options.ttl).toString();
66
111
  await this.#sql`
67
- INSERT INTO ${this.#sql(this.#tableName)} (key, value, ttl)
112
+ INSERT INTO ${this.#sql(this.#tableName)} (key, value, created, ttl)
68
113
  VALUES (
69
114
  ${key},
70
115
  ${this.#json(value)},
116
+ statement_timestamp(),
71
117
  ${ttl}
72
118
  )
73
119
  ON CONFLICT (key)
74
120
  DO UPDATE SET
75
121
  value = EXCLUDED.value,
76
- created = CURRENT_TIMESTAMP,
122
+ created = statement_timestamp(),
77
123
  ttl = EXCLUDED.ttl;
78
124
  `;
79
125
  await this.#expire();
@@ -101,7 +147,7 @@ var PostgresKvStore = class {
101
147
  VALUES (
102
148
  ${key},
103
149
  ${this.#json(newValue)},
104
- CURRENT_TIMESTAMP,
150
+ statement_timestamp(),
105
151
  ${ttl}
106
152
  )
107
153
  ON CONFLICT (key)
@@ -110,13 +156,13 @@ var PostgresKvStore = class {
110
156
  created = EXCLUDED.created,
111
157
  ttl = EXCLUDED.ttl
112
158
  WHERE existing.ttl IS NOT NULL
113
- AND existing.created + existing.ttl <= CURRENT_TIMESTAMP
159
+ AND existing.created + existing.ttl <= statement_timestamp()
114
160
  RETURNING key;
115
161
  `;
116
162
  else if (newValue === void 0) result = await this.#sql`
117
163
  DELETE FROM ${this.#sql(this.#tableName)}
118
164
  WHERE key = ${key}
119
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
165
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
120
166
  AND value = ${this.#json(expectedValue)}
121
167
  RETURNING key;
122
168
  `;
@@ -124,10 +170,10 @@ var PostgresKvStore = class {
124
170
  UPDATE ${this.#sql(this.#tableName)}
125
171
  SET
126
172
  value = ${this.#json(newValue)},
127
- created = CURRENT_TIMESTAMP,
173
+ created = statement_timestamp(),
128
174
  ttl = ${ttl}
129
175
  WHERE key = ${key}
130
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
176
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
131
177
  AND value = ${this.#json(expectedValue)}
132
178
  RETURNING key;
133
179
  `;
@@ -144,7 +190,7 @@ var PostgresKvStore = class {
144
190
  if (prefix == null || prefix.length === 0) results = await this.#sql`
145
191
  SELECT key, value
146
192
  FROM ${this.#sql(this.#tableName)}
147
- WHERE ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP
193
+ WHERE ttl IS NULL OR created + ttl > statement_timestamp()
148
194
  ORDER BY key
149
195
  `;
150
196
  else {
@@ -154,7 +200,7 @@ var PostgresKvStore = class {
154
200
  FROM ${this.#sql(this.#tableName)}
155
201
  WHERE array_length(key, 1) >= ${prefixLength}
156
202
  AND key[1:${prefixLength}] = ${prefix}::text[]
157
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
203
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
158
204
  ORDER BY key
159
205
  `;
160
206
  }
@@ -215,6 +261,7 @@ var PostgresKvStore = class {
215
261
  * does not exist.
216
262
  */
217
263
  async drop() {
264
+ await this.#expiring;
218
265
  await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
219
266
  }
220
267
  #json(value) {
package/dist/mq.d.cts CHANGED
@@ -35,9 +35,14 @@ interface PostgresMessageQueueOptions {
35
35
  readonly pollInterval?: Temporal.Duration | Temporal.DurationLike;
36
36
  /**
37
37
  * The maximum time to wait for a message handler to complete before
38
- * considering it hung. When a handler exceeds this timeout, it is
39
- * treated as an error and the poll loop moves on to the next message,
40
- * preventing a single hung handler from permanently blocking the queue.
38
+ * the queue moves on. This is a soft timeout: the queue logs a timeout
39
+ * error and stops waiting, but cannot cancel the handler. The handler
40
+ * continues running and may still produce side effects.
41
+ *
42
+ * The ordered-key path releases its advisory lock and reserved connection
43
+ * through its cleanup path. This does not release connections or
44
+ * transactions held by application code. A subsequent handler for the
45
+ * same key may run while the timed-out handler is still active.
41
46
  *
42
47
  * Set to zero to disable the timeout (not recommended in production).
43
48
  *
package/dist/mq.d.ts CHANGED
@@ -35,9 +35,14 @@ interface PostgresMessageQueueOptions {
35
35
  readonly pollInterval?: Temporal.Duration | Temporal.DurationLike;
36
36
  /**
37
37
  * The maximum time to wait for a message handler to complete before
38
- * considering it hung. When a handler exceeds this timeout, it is
39
- * treated as an error and the poll loop moves on to the next message,
40
- * preventing a single hung handler from permanently blocking the queue.
38
+ * the queue moves on. This is a soft timeout: the queue logs a timeout
39
+ * error and stops waiting, but cannot cancel the handler. The handler
40
+ * continues running and may still produce side effects.
41
+ *
42
+ * The ordered-key path releases its advisory lock and reserved connection
43
+ * through its cleanup path. This does not release connections or
44
+ * transactions held by application code. A subsequent handler for the
45
+ * same key may run while the timed-out handler is still active.
41
46
  *
42
47
  * Set to zero to disable the timeout (not recommended in production).
43
48
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/postgres",
3
- "version": "2.4.5-dev.2453+1304e862",
3
+ "version": "2.4.5",
4
4
  "description": "PostgreSQL drivers for Fedify",
5
5
  "keywords": [
6
6
  "fedify",
@@ -71,15 +71,15 @@
71
71
  },
72
72
  "peerDependencies": {
73
73
  "postgres": "^3.4.7",
74
- "@fedify/fedify": "^2.4.5-dev.2453+1304e862"
74
+ "@fedify/fedify": "^2.4.5"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@js-temporal/polyfill": "^0.5.1",
78
78
  "@std/async": "npm:@jsr/std__async@^1.0.13",
79
79
  "tsdown": "^0.22.0",
80
80
  "typescript": "^6.0.0",
81
- "@fedify/testing": "^2.4.5-dev.2453+1304e862",
82
- "@fedify/fixture": "^2.0.0"
81
+ "@fedify/fixture": "^2.0.0",
82
+ "@fedify/testing": "^2.4.5"
83
83
  },
84
84
  "scripts": {
85
85
  "build:self": "tsdown",