@fedify/postgres 2.0.34 → 2.0.35-dev.2465

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/kv.cjs +59 -9
  2. package/dist/kv.js +59 -9
  3. package/package.json +3 -3
package/dist/kv.cjs CHANGED
@@ -8,6 +8,9 @@ const logger = (0, require("@logtape/logtape").getLogger)([
8
8
  "postgres",
9
9
  "kv"
10
10
  ]);
11
+ const expiryBatchSize = 1e3;
12
+ const expiryInterval = 6e4;
13
+ const expiryCatchUpInterval = 1e3;
11
14
  /**
12
15
  * A key–value store that uses PostgreSQL as the underlying storage.
13
16
  *
@@ -29,6 +32,8 @@ var PostgresKvStore = class {
29
32
  #skipDdl;
30
33
  #initialized = false;
31
34
  #driverSerializesJson = false;
35
+ #expiring;
36
+ #nextExpiry = 0;
32
37
  /**
33
38
  * Creates a new PostgreSQL key–value store.
34
39
  * @param sql The PostgreSQL client to use.
@@ -39,18 +44,58 @@ var PostgresKvStore = class {
39
44
  this.#tableName = options.tableName ?? "fedify_kv_v2";
40
45
  this.#skipDdl = options.initialized ?? false;
41
46
  }
42
- async #expire() {
43
- await this.#sql`
44
- DELETE FROM ${this.#sql(this.#tableName)}
45
- WHERE ttl IS NOT NULL AND created + ttl < CURRENT_TIMESTAMP;
47
+ #expire() {
48
+ const callerOwned = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
49
+ if (this.#expiring) return callerOwned ? this.#expiring : void 0;
50
+ if (performance.now() < this.#nextExpiry) return;
51
+ this.#expiring = (async () => {
52
+ let interval = expiryInterval;
53
+ try {
54
+ if (await this.#sweep() === expiryBatchSize) interval = expiryCatchUpInterval;
55
+ } catch (error) {
56
+ if (callerOwned) throw error;
57
+ logger.warn("Failed to expire keys in {tableName}: {error}", {
58
+ tableName: this.#tableName,
59
+ error
60
+ });
61
+ } finally {
62
+ this.#nextExpiry = performance.now() + interval * (1 + Math.random());
63
+ this.#expiring = void 0;
64
+ }
65
+ })();
66
+ return callerOwned ? this.#expiring : void 0;
67
+ }
68
+ async #sweep() {
69
+ const [result] = await this.#sql`
70
+ WITH sweep_lock AS MATERIALIZED (
71
+ SELECT pg_try_advisory_xact_lock(
72
+ (hashtext('fedify.postgres.kv.expire')::bigint << 32) |
73
+ (SELECT typrelid::bigint FROM pg_catalog.pg_type
74
+ WHERE oid = pg_typeof(NULL::${this.#sql(this.#tableName)})::oid)
75
+ ) AS acquired
76
+ ), candidates AS MATERIALIZED (
77
+ SELECT tableoid AS relation, ctid AS tid
78
+ FROM ${this.#sql(this.#tableName)}
79
+ WHERE (SELECT acquired FROM sweep_lock)
80
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
81
+ LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
82
+ ), deleted AS (
83
+ DELETE FROM ${this.#sql(this.#tableName)}
84
+ WHERE ctid = ANY(ARRAY(SELECT tid FROM candidates))
85
+ AND (tableoid, ctid) IN (SELECT relation, tid FROM candidates)
86
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
87
+ RETURNING 1
88
+ )
89
+ SELECT count(*)::int AS count FROM deleted;
46
90
  `;
91
+ return result.count;
47
92
  }
48
93
  async get(key) {
49
94
  await this.initialize();
50
95
  const result = await this.#sql`
51
96
  SELECT value
52
97
  FROM ${this.#sql(this.#tableName)}
53
- WHERE key = ${key} AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP);
98
+ WHERE key = ${key} AND (ttl IS NULL OR created + ttl > statement_timestamp());
54
99
  `;
55
100
  if (result.length < 1) return void 0;
56
101
  return result[0].value;
@@ -60,14 +105,18 @@ var PostgresKvStore = class {
60
105
  const ttl = options?.ttl == null ? null : options.ttl.toString();
61
106
  const jsonValue = value === null ? this.#sql`'null'::jsonb` : this.#json(value);
62
107
  await this.#sql`
63
- INSERT INTO ${this.#sql(this.#tableName)} (key, value, ttl)
108
+ INSERT INTO ${this.#sql(this.#tableName)} (key, value, created, ttl)
64
109
  VALUES (
65
110
  ${key},
66
111
  ${jsonValue},
112
+ statement_timestamp(),
67
113
  ${ttl}
68
114
  )
69
115
  ON CONFLICT (key)
70
- DO UPDATE SET value = EXCLUDED.value, ttl = EXCLUDED.ttl;
116
+ DO UPDATE SET
117
+ value = EXCLUDED.value,
118
+ created = statement_timestamp(),
119
+ ttl = EXCLUDED.ttl;
71
120
  `;
72
121
  await this.#expire();
73
122
  }
@@ -89,7 +138,7 @@ var PostgresKvStore = class {
89
138
  if (prefix == null || prefix.length === 0) results = await this.#sql`
90
139
  SELECT key, value
91
140
  FROM ${this.#sql(this.#tableName)}
92
- WHERE ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP
141
+ WHERE ttl IS NULL OR created + ttl > statement_timestamp()
93
142
  ORDER BY key
94
143
  `;
95
144
  else {
@@ -99,7 +148,7 @@ var PostgresKvStore = class {
99
148
  FROM ${this.#sql(this.#tableName)}
100
149
  WHERE array_length(key, 1) >= ${prefixLength}
101
150
  AND key[1:${prefixLength}] = ${prefix}::text[]
102
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
151
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
103
152
  ORDER BY key
104
153
  `;
105
154
  }
@@ -135,6 +184,7 @@ var PostgresKvStore = class {
135
184
  * does not exist.
136
185
  */
137
186
  async drop() {
187
+ await this.#expiring;
138
188
  await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
139
189
  }
140
190
  #json(value) {
package/dist/kv.js CHANGED
@@ -7,6 +7,9 @@ const logger = getLogger([
7
7
  "postgres",
8
8
  "kv"
9
9
  ]);
10
+ const expiryBatchSize = 1e3;
11
+ const expiryInterval = 6e4;
12
+ const expiryCatchUpInterval = 1e3;
10
13
  /**
11
14
  * A key–value store that uses PostgreSQL as the underlying storage.
12
15
  *
@@ -28,6 +31,8 @@ var PostgresKvStore = class {
28
31
  #skipDdl;
29
32
  #initialized = false;
30
33
  #driverSerializesJson = false;
34
+ #expiring;
35
+ #nextExpiry = 0;
31
36
  /**
32
37
  * Creates a new PostgreSQL key–value store.
33
38
  * @param sql The PostgreSQL client to use.
@@ -38,18 +43,58 @@ var PostgresKvStore = class {
38
43
  this.#tableName = options.tableName ?? "fedify_kv_v2";
39
44
  this.#skipDdl = options.initialized ?? false;
40
45
  }
41
- async #expire() {
42
- await this.#sql`
43
- DELETE FROM ${this.#sql(this.#tableName)}
44
- WHERE ttl IS NOT NULL AND created + ttl < CURRENT_TIMESTAMP;
46
+ #expire() {
47
+ const callerOwned = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
48
+ if (this.#expiring) return callerOwned ? this.#expiring : void 0;
49
+ if (performance.now() < this.#nextExpiry) return;
50
+ this.#expiring = (async () => {
51
+ let interval = expiryInterval;
52
+ try {
53
+ if (await this.#sweep() === expiryBatchSize) interval = expiryCatchUpInterval;
54
+ } catch (error) {
55
+ if (callerOwned) throw error;
56
+ logger.warn("Failed to expire keys in {tableName}: {error}", {
57
+ tableName: this.#tableName,
58
+ error
59
+ });
60
+ } finally {
61
+ this.#nextExpiry = performance.now() + interval * (1 + Math.random());
62
+ this.#expiring = void 0;
63
+ }
64
+ })();
65
+ return callerOwned ? this.#expiring : void 0;
66
+ }
67
+ async #sweep() {
68
+ const [result] = await this.#sql`
69
+ WITH sweep_lock AS MATERIALIZED (
70
+ SELECT pg_try_advisory_xact_lock(
71
+ (hashtext('fedify.postgres.kv.expire')::bigint << 32) |
72
+ (SELECT typrelid::bigint FROM pg_catalog.pg_type
73
+ WHERE oid = pg_typeof(NULL::${this.#sql(this.#tableName)})::oid)
74
+ ) AS acquired
75
+ ), candidates AS MATERIALIZED (
76
+ SELECT tableoid AS relation, ctid AS tid
77
+ FROM ${this.#sql(this.#tableName)}
78
+ WHERE (SELECT acquired FROM sweep_lock)
79
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
80
+ LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
81
+ ), deleted AS (
82
+ DELETE FROM ${this.#sql(this.#tableName)}
83
+ WHERE ctid = ANY(ARRAY(SELECT tid FROM candidates))
84
+ AND (tableoid, ctid) IN (SELECT relation, tid FROM candidates)
85
+ AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
86
+ RETURNING 1
87
+ )
88
+ SELECT count(*)::int AS count FROM deleted;
45
89
  `;
90
+ return result.count;
46
91
  }
47
92
  async get(key) {
48
93
  await this.initialize();
49
94
  const result = await this.#sql`
50
95
  SELECT value
51
96
  FROM ${this.#sql(this.#tableName)}
52
- WHERE key = ${key} AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP);
97
+ WHERE key = ${key} AND (ttl IS NULL OR created + ttl > statement_timestamp());
53
98
  `;
54
99
  if (result.length < 1) return void 0;
55
100
  return result[0].value;
@@ -59,14 +104,18 @@ var PostgresKvStore = class {
59
104
  const ttl = options?.ttl == null ? null : options.ttl.toString();
60
105
  const jsonValue = value === null ? this.#sql`'null'::jsonb` : this.#json(value);
61
106
  await this.#sql`
62
- INSERT INTO ${this.#sql(this.#tableName)} (key, value, ttl)
107
+ INSERT INTO ${this.#sql(this.#tableName)} (key, value, created, ttl)
63
108
  VALUES (
64
109
  ${key},
65
110
  ${jsonValue},
111
+ statement_timestamp(),
66
112
  ${ttl}
67
113
  )
68
114
  ON CONFLICT (key)
69
- DO UPDATE SET value = EXCLUDED.value, ttl = EXCLUDED.ttl;
115
+ DO UPDATE SET
116
+ value = EXCLUDED.value,
117
+ created = statement_timestamp(),
118
+ ttl = EXCLUDED.ttl;
70
119
  `;
71
120
  await this.#expire();
72
121
  }
@@ -88,7 +137,7 @@ var PostgresKvStore = class {
88
137
  if (prefix == null || prefix.length === 0) results = await this.#sql`
89
138
  SELECT key, value
90
139
  FROM ${this.#sql(this.#tableName)}
91
- WHERE ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP
140
+ WHERE ttl IS NULL OR created + ttl > statement_timestamp()
92
141
  ORDER BY key
93
142
  `;
94
143
  else {
@@ -98,7 +147,7 @@ var PostgresKvStore = class {
98
147
  FROM ${this.#sql(this.#tableName)}
99
148
  WHERE array_length(key, 1) >= ${prefixLength}
100
149
  AND key[1:${prefixLength}] = ${prefix}::text[]
101
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
150
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
102
151
  ORDER BY key
103
152
  `;
104
153
  }
@@ -134,6 +183,7 @@ var PostgresKvStore = class {
134
183
  * does not exist.
135
184
  */
136
185
  async drop() {
186
+ await this.#expiring;
137
187
  await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
138
188
  }
139
189
  #json(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/postgres",
3
- "version": "2.0.34",
3
+ "version": "2.0.35-dev.2465+5a048d17",
4
4
  "description": "PostgreSQL drivers for Fedify",
5
5
  "keywords": [
6
6
  "fedify",
@@ -74,14 +74,14 @@
74
74
  },
75
75
  "peerDependencies": {
76
76
  "postgres": "^3.4.7",
77
- "@fedify/fedify": "^2.0.34"
77
+ "@fedify/fedify": "^2.0.35-dev.2465+5a048d17"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@std/async": "npm:@jsr/std__async@^1.0.13",
81
81
  "tsdown": "^0.21.6",
82
82
  "typescript": "^6.0.0",
83
83
  "@fedify/fixture": "^2.0.0",
84
- "@fedify/testing": "^2.0.34"
84
+ "@fedify/testing": "^2.0.35-dev.2465+5a048d17"
85
85
  },
86
86
  "scripts": {
87
87
  "build:self": "tsdown",