@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.
- package/dist/kv.cjs +56 -9
- package/dist/kv.js +56 -9
- 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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 >
|
|
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 =
|
|
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 >
|
|
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 >
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 >
|
|
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 =
|
|
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 >
|
|
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 >
|
|
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.
|
|
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.
|
|
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.
|
|
84
|
+
"@fedify/testing": "^2.3.15"
|
|
85
85
|
},
|
|
86
86
|
"scripts": {
|
|
87
87
|
"build:self": "tsdown",
|