@fedify/postgres 2.3.14 → 2.3.15

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 +56 -9
  2. package/dist/kv.js +56 -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,16 +105,17 @@ var PostgresKvStore = class {
60
105
  const ttl = options?.ttl == null ? null : Temporal.Duration.from(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
116
  DO UPDATE SET
71
117
  value = EXCLUDED.value,
72
- created = CURRENT_TIMESTAMP,
118
+ created = statement_timestamp(),
73
119
  ttl = EXCLUDED.ttl;
74
120
  `;
75
121
  await this.#expire();
@@ -92,7 +138,7 @@ var PostgresKvStore = class {
92
138
  if (prefix == null || prefix.length === 0) results = await this.#sql`
93
139
  SELECT key, value
94
140
  FROM ${this.#sql(this.#tableName)}
95
- WHERE ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP
141
+ WHERE ttl IS NULL OR created + ttl > statement_timestamp()
96
142
  ORDER BY key
97
143
  `;
98
144
  else {
@@ -102,7 +148,7 @@ var PostgresKvStore = class {
102
148
  FROM ${this.#sql(this.#tableName)}
103
149
  WHERE array_length(key, 1) >= ${prefixLength}
104
150
  AND key[1:${prefixLength}] = ${prefix}::text[]
105
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
151
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
106
152
  ORDER BY key
107
153
  `;
108
154
  }
@@ -138,6 +184,7 @@ var PostgresKvStore = class {
138
184
  * does not exist.
139
185
  */
140
186
  async drop() {
187
+ await this.#expiring;
141
188
  await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
142
189
  }
143
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,16 +104,17 @@ var PostgresKvStore = class {
59
104
  const ttl = options?.ttl == null ? null : Temporal.Duration.from(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
115
  DO UPDATE SET
70
116
  value = EXCLUDED.value,
71
- created = CURRENT_TIMESTAMP,
117
+ created = statement_timestamp(),
72
118
  ttl = EXCLUDED.ttl;
73
119
  `;
74
120
  await this.#expire();
@@ -91,7 +137,7 @@ var PostgresKvStore = class {
91
137
  if (prefix == null || prefix.length === 0) results = await this.#sql`
92
138
  SELECT key, value
93
139
  FROM ${this.#sql(this.#tableName)}
94
- WHERE ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP
140
+ WHERE ttl IS NULL OR created + ttl > statement_timestamp()
95
141
  ORDER BY key
96
142
  `;
97
143
  else {
@@ -101,7 +147,7 @@ var PostgresKvStore = class {
101
147
  FROM ${this.#sql(this.#tableName)}
102
148
  WHERE array_length(key, 1) >= ${prefixLength}
103
149
  AND key[1:${prefixLength}] = ${prefix}::text[]
104
- AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
150
+ AND (ttl IS NULL OR created + ttl > statement_timestamp())
105
151
  ORDER BY key
106
152
  `;
107
153
  }
@@ -137,6 +183,7 @@ var PostgresKvStore = class {
137
183
  * does not exist.
138
184
  */
139
185
  async drop() {
186
+ await this.#expiring;
140
187
  await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
141
188
  }
142
189
  #json(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/postgres",
3
- "version": "2.3.14",
3
+ "version": "2.3.15",
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.3.14"
77
+ "@fedify/fedify": "^2.3.15"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@std/async": "npm:@jsr/std__async@^1.0.13",
81
81
  "tsdown": "^0.22.0",
82
82
  "typescript": "^6.0.0",
83
83
  "@fedify/fixture": "^2.0.0",
84
- "@fedify/testing": "^2.3.14"
84
+ "@fedify/testing": "^2.3.15"
85
85
  },
86
86
  "scripts": {
87
87
  "build:self": "tsdown",