@sema-agent/server 7.58.0 → 7.60.0
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/README.md +4 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +10 -1
- package/dist/approval-card.d.ts +47 -0
- package/dist/approval-card.js +18 -0
- package/dist/boot/parked-revive-gate.d.ts +3 -0
- package/dist/boot/parked-revive-gate.js +2 -2
- package/dist/boot/resolve-spec.js +1 -1
- package/dist/boot/runner-deps.d.ts +2 -2
- package/dist/boot/runner-deps.js +4 -0
- package/dist/boot/stores.d.ts +1 -0
- package/dist/boot/stores.js +7 -4
- package/dist/config-catalog.d.ts +0 -29
- package/dist/config-catalog.js +3 -0
- package/dist/config-types.d.ts +12 -2
- package/dist/config.js +13 -2
- package/dist/cross-session-settings.d.ts +70 -0
- package/dist/cross-session-settings.js +37 -0
- package/dist/deployment-governance.d.ts +30 -1
- package/dist/deployment-governance.js +11 -5
- package/dist/http/routes/approvals-assistant.js +3 -0
- package/dist/http/routes/capabilities.js +2 -0
- package/dist/http/routes/diagnostics.js +1 -0
- package/dist/http/server.d.ts +6 -0
- package/dist/main.js +11 -4
- package/dist/memory-scope.d.ts +20 -0
- package/dist/memory-scope.js +9 -1
- package/dist/observability/run-terminal-log.d.ts +4 -0
- package/dist/observability/run-terminal-log.js +14 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +9 -9
- package/dist/plugins/approval-ask-store-sql.js +1 -1
- package/dist/plugins/device-store-sql.d.ts +38 -10
- package/dist/plugins/device-store-sql.js +28 -9
- package/dist/plugins/file-history-store-sql.d.ts +20 -3
- package/dist/plugins/file-history-store-sql.js +39 -36
- package/dist/plugins/image-bake-store-sql.js +2 -2
- package/dist/plugins/mailbox-store-sql.d.ts +5 -2
- package/dist/plugins/mailbox-store-sql.js +2 -2
- package/dist/plugins/permission-rule-store-file.d.ts +4 -4
- package/dist/plugins/permission-rule-store-file.js +4 -4
- package/dist/plugins/permission-rule-store-sql.d.ts +15 -11
- package/dist/plugins/permission-rule-store-sql.js +7 -11
- package/dist/plugins/retention-lane-store-sql.d.ts +10 -6
- package/dist/plugins/retention-lane-store-sql.js +1 -1
- package/dist/plugins/retention-store-sql.d.ts +11 -15
- package/dist/plugins/retention-store-sql.js +6 -2
- package/dist/plugins/session-policy-store-sql.d.ts +9 -2
- package/dist/plugins/shared-memory-store-sql.d.ts +25 -19
- package/dist/plugins/shared-memory-store-sql.js +17 -15
- package/dist/plugins/sql-driver.d.ts +78 -26
- package/dist/plugins/sql-driver.js +97 -14
- package/dist/plugins/store-backend.d.ts +6 -2
- package/dist/plugins/task-list-store-sql.d.ts +2 -2
- package/dist/plugins/task-list-store-sql.js +1 -1
- package/dist/plugins/usage-window-store-sql.d.ts +5 -3
- package/dist/plugins/usage-window-store-sql.js +1 -5
- package/dist/rules-consent.d.ts +54 -12
- package/dist/rules-consent.js +4 -4
- package/dist/run-local.d.ts +2 -1
- package/dist/run-local.js +3 -3
- package/dist/runs.d.ts +13 -0
- package/dist/runs.js +2 -0
- package/dist/sql-engine-posture.d.ts +49 -0
- package/dist/sql-engine-posture.js +13 -0
- package/dist/task-settings.d.ts +13 -3
- package/dist/task-settings.js +1 -1
- package/dist/tool-approval.d.ts +41 -1
- package/dist/tool-approval.js +7 -1
- package/dist/trace/core-keyset-guard.d.ts +4 -4
- package/dist/trace/engine-notice-wire.d.ts +1 -1
- package/dist/trace/engine-notice-wire.js +3 -0
- package/dist/trace/project.d.ts +25 -0
- package/dist/trace/project.js +26 -0
- package/dist/trace/sema-provenance.d.ts +7 -1
- package/dist/trace/sema-provenance.js +1 -1
- package/package.json +3 -3
|
@@ -49,7 +49,7 @@ export class SqlFileHistoryStore {
|
|
|
49
49
|
for (const scope of [...new Set(scopes)].sort()) {
|
|
50
50
|
let locked = false;
|
|
51
51
|
for (let i = 0; i < 3 && !locked; i++) {
|
|
52
|
-
await conn.query(this.q("INSERT
|
|
52
|
+
await conn.query(this.q("INSERT INTO file_history_scope (scope, created_at_ms) VALUES (?,?) ON DUPLICATE KEY UPDATE scope = scope", "INSERT INTO file_history_scope (scope, created_at_ms) VALUES ($1,$2) ON CONFLICT (scope) DO NOTHING"), [scope, Date.now()]);
|
|
53
53
|
const r = await conn.query(this.q("SELECT scope FROM file_history_scope WHERE scope = ? FOR UPDATE", "SELECT scope FROM file_history_scope WHERE scope = $1 FOR UPDATE"), [scope]);
|
|
54
54
|
locked = Boolean(r.rows[0]);
|
|
55
55
|
}
|
|
@@ -92,7 +92,7 @@ export class SqlFileHistoryStore {
|
|
|
92
92
|
const conn = await this.db.connect();
|
|
93
93
|
let minted = false;
|
|
94
94
|
try {
|
|
95
|
-
await conn.
|
|
95
|
+
await conn.begin();
|
|
96
96
|
await this.lockScope(conn, scope);
|
|
97
97
|
const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES (?,?,?,?,?,?,0,?)", "INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6,FALSE,$7) ON CONFLICT (scope, key_hash) DO NOTHING"), [scope, keyHash, key, keyDesc.kind, keyDesc.family, this.bool(baseAvailable), Date.now()]);
|
|
98
98
|
minted = ins.affected === 1;
|
|
@@ -159,7 +159,7 @@ export class SqlFileHistoryStore {
|
|
|
159
159
|
}
|
|
160
160
|
const conn = await this.db.connect();
|
|
161
161
|
try {
|
|
162
|
-
await conn.
|
|
162
|
+
await conn.begin();
|
|
163
163
|
await this.lockScope(conn, scope);
|
|
164
164
|
const del = await conn.query(this.q(`DELETE FROM file_history_tracked WHERE scope = ? AND key_hash = ? AND observed_later = 0
|
|
165
165
|
AND NOT EXISTS (SELECT 1 FROM file_history_boundary_file bf WHERE bf.scope = ? AND bf.key_hash = ?)
|
|
@@ -202,8 +202,9 @@ export class SqlFileHistoryStore {
|
|
|
202
202
|
baseAvailable: truthy(row.base_available),
|
|
203
203
|
}));
|
|
204
204
|
}
|
|
205
|
-
async loadVersions(exec, scope) {
|
|
206
|
-
const
|
|
205
|
+
async loadVersions(exec, scope, forUpdate = false) {
|
|
206
|
+
const suffix = forUpdate ? " FOR UPDATE" : "";
|
|
207
|
+
const r = await exec.query(this.q(`SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = ? ORDER BY version${suffix}`, `SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = $1 ORDER BY version${suffix}`), [scope]);
|
|
207
208
|
const out = new Map();
|
|
208
209
|
for (const row of r.rows) {
|
|
209
210
|
const kh = String(row.key_hash);
|
|
@@ -240,9 +241,9 @@ export class SqlFileHistoryStore {
|
|
|
240
241
|
await this.beforeRetentionPass(scope);
|
|
241
242
|
const conn = await this.db.connect();
|
|
242
243
|
try {
|
|
243
|
-
await conn.
|
|
244
|
+
await conn.begin();
|
|
244
245
|
await this.lockScope(conn, scope);
|
|
245
|
-
const { rows } = await conn.query(this.q("SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq ASC", "SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq ASC"), [scope]);
|
|
246
|
+
const { rows } = await conn.query(this.q("SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq ASC FOR UPDATE", "SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq ASC FOR UPDATE"), [scope]);
|
|
246
247
|
const ordered = rows.map((r) => ({ id: String(r.entry_id), seq: Number(r.publish_seq) }));
|
|
247
248
|
if (ordered.length === 0) {
|
|
248
249
|
await conn.rollback();
|
|
@@ -275,8 +276,9 @@ export class SqlFileHistoryStore {
|
|
|
275
276
|
recordFailOpen("server.file-history.retention-trim-failed", `scope=${scope} entry=${justCommitted}: ${err instanceof Error ? err.message : String(err)}`);
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
|
-
async boundaryExists(exec, scope, entryId) {
|
|
279
|
-
const
|
|
279
|
+
async boundaryExists(exec, scope, entryId, forUpdate = false) {
|
|
280
|
+
const suffix = forUpdate ? " FOR UPDATE" : "";
|
|
281
|
+
const r = await exec.query(this.q(`SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 LIMIT 1${suffix}`), [scope, entryId]);
|
|
280
282
|
return Boolean(r.rows[0]);
|
|
281
283
|
}
|
|
282
284
|
async snapshot(scope, entryId, env, root, signal) {
|
|
@@ -344,7 +346,7 @@ export class SqlFileHistoryStore {
|
|
|
344
346
|
await this.beforePublish(scope);
|
|
345
347
|
const conn = await this.db.connect();
|
|
346
348
|
try {
|
|
347
|
-
await conn.
|
|
349
|
+
await conn.begin();
|
|
348
350
|
await this.beforePublishLock(scope);
|
|
349
351
|
await this.lockScope(conn, scope);
|
|
350
352
|
if (signal?.aborted) {
|
|
@@ -356,17 +358,17 @@ export class SqlFileHistoryStore {
|
|
|
356
358
|
await conn.rollback();
|
|
357
359
|
return reapedEntryRefusal(entryId);
|
|
358
360
|
}
|
|
359
|
-
const seqRow = await conn.query(this.q("SELECT publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE", "SELECT publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq DESC LIMIT 1"), [scope]);
|
|
361
|
+
const seqRow = await conn.query(this.q("SELECT publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE", "SELECT publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE"), [scope]);
|
|
360
362
|
const publishSeq = (seqRow.rows[0] !== undefined ? Number(seqRow.rows[0].publish_seq) : 0) + 1;
|
|
361
|
-
const
|
|
362
|
-
if (
|
|
363
|
-
const won = await conn.query(this.q("SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
|
|
364
|
-
if (!won.rows[0]) {
|
|
365
|
-
throw new Error(`boundary publish for entry "${entryId}" was suppressed without a same-entry winner (publish_seq allocation invariant breach) — refusing to report a boundary that was not stored`);
|
|
366
|
-
}
|
|
363
|
+
const priorWinner = await conn.query(this.q("SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
|
|
364
|
+
if (priorWinner.rows[0]) {
|
|
367
365
|
await conn.rollback();
|
|
368
366
|
return { ok: true };
|
|
369
367
|
}
|
|
368
|
+
const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES (?,?,CAST(UNIX_TIMESTAMP(NOW(3))*1000 AS SIGNED),?)", "INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES ($1,$2,(EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint,$3) ON CONFLICT (scope, entry_id) DO NOTHING"), [scope, entryId, publishSeq]);
|
|
369
|
+
if (ins.affected !== 1) {
|
|
370
|
+
throw new Error(`boundary publish for entry "${entryId}" was suppressed without a same-entry winner (publish_seq allocation invariant breach) — refusing to report a boundary that was not stored`);
|
|
371
|
+
}
|
|
370
372
|
for (const [keyHash, cap] of staged) {
|
|
371
373
|
let version;
|
|
372
374
|
if (cap.kind === "unchanged") {
|
|
@@ -496,16 +498,16 @@ export class SqlFileHistoryStore {
|
|
|
496
498
|
async restorePlan(scope, entryId) {
|
|
497
499
|
const conn = await this.db.connect();
|
|
498
500
|
try {
|
|
499
|
-
await conn.
|
|
501
|
+
await conn.begin();
|
|
500
502
|
await this.lockScope(conn, scope);
|
|
501
|
-
if (!(await this.boundaryExists(conn, scope, entryId))) {
|
|
503
|
+
if (!(await this.boundaryExists(conn, scope, entryId, true))) {
|
|
502
504
|
await conn.rollback();
|
|
503
505
|
return null;
|
|
504
506
|
}
|
|
505
507
|
await this.duringRestorePlan(scope, entryId);
|
|
506
|
-
const tracked = (await this.loadTracked(conn, scope)).sort((a, b) => (a.trackKey < b.trackKey ? -1 : a.trackKey > b.trackKey ? 1 : 0));
|
|
507
|
-
const versions = await this.loadVersions(conn, scope);
|
|
508
|
-
const mapRows = await conn.query(this.q("SELECT key_hash, version FROM file_history_boundary_file WHERE scope = ? AND entry_id = ?", "SELECT key_hash, version FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2"), [scope, entryId]);
|
|
508
|
+
const tracked = (await this.loadTracked(conn, scope, true)).sort((a, b) => (a.trackKey < b.trackKey ? -1 : a.trackKey > b.trackKey ? 1 : 0));
|
|
509
|
+
const versions = await this.loadVersions(conn, scope, true);
|
|
510
|
+
const mapRows = await conn.query(this.q("SELECT key_hash, version FROM file_history_boundary_file WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT key_hash, version FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
|
|
509
511
|
const boundary = new Map(mapRows.rows.map((r) => [String(r.key_hash), Number(r.version)]));
|
|
510
512
|
await conn.commit();
|
|
511
513
|
return { tracked, versions, boundary };
|
|
@@ -524,9 +526,9 @@ export class SqlFileHistoryStore {
|
|
|
524
526
|
async reap(scope, keepEntryIds) {
|
|
525
527
|
const conn = await this.db.connect();
|
|
526
528
|
try {
|
|
527
|
-
await conn.
|
|
529
|
+
await conn.begin();
|
|
528
530
|
await this.lockScope(conn, scope);
|
|
529
|
-
const ids = await conn.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ?", "SELECT entry_id FROM file_history_boundary WHERE scope = $1"), [scope]);
|
|
531
|
+
const ids = await conn.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ? FOR UPDATE", "SELECT entry_id FROM file_history_boundary WHERE scope = $1 FOR UPDATE"), [scope]);
|
|
530
532
|
const dropped = await this.reapWithin(conn, scope, new Set(keepEntryIds), ids.rows.map((r) => String(r.entry_id)));
|
|
531
533
|
await conn.commit();
|
|
532
534
|
return dropped;
|
|
@@ -557,7 +559,7 @@ export class SqlFileHistoryStore {
|
|
|
557
559
|
async deleteBySession(scope) {
|
|
558
560
|
const conn = await this.db.connect();
|
|
559
561
|
try {
|
|
560
|
-
await conn.
|
|
562
|
+
await conn.begin();
|
|
561
563
|
await this.lockScope(conn, scope);
|
|
562
564
|
let total = 0;
|
|
563
565
|
for (const table of ["file_history_boundary_file", "file_history_boundary", "file_history_reaped_entry", "file_history_version", "file_history_tracked", "file_history_scope"]) {
|
|
@@ -606,13 +608,13 @@ export class SqlFileHistoryStore {
|
|
|
606
608
|
return { ok: true };
|
|
607
609
|
const conn = await this.db.connect();
|
|
608
610
|
try {
|
|
609
|
-
await conn.
|
|
611
|
+
await conn.begin();
|
|
610
612
|
await this.lockScope(conn, fromScope, toScope);
|
|
611
613
|
if (!(await this.scopeEmpty(conn, toScope, true))) {
|
|
612
614
|
await conn.rollback();
|
|
613
615
|
return { ok: false, error: { code: "conflict", message: `adoptScope target "${toScope}" already has history of its own — adoption is legal only onto an EMPTY scope (the fork/resume boundary before the new session's first turn)` } };
|
|
614
616
|
}
|
|
615
|
-
if (await this.scopeEmpty(conn, fromScope)) {
|
|
617
|
+
if (await this.scopeEmpty(conn, fromScope, true)) {
|
|
616
618
|
await this.copyReapedEntries(conn, fromScope, toScope);
|
|
617
619
|
await conn.commit();
|
|
618
620
|
return { ok: true };
|
|
@@ -645,14 +647,15 @@ export class SqlFileHistoryStore {
|
|
|
645
647
|
const b = await exec.query(this.q(`SELECT 1 FROM file_history_boundary WHERE scope = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_boundary WHERE scope = $1 LIMIT 1${suffix}`), [scope]);
|
|
646
648
|
return !b.rows[0];
|
|
647
649
|
}
|
|
648
|
-
async exportOf(exec, scope) {
|
|
649
|
-
const
|
|
650
|
-
const
|
|
651
|
-
const
|
|
650
|
+
async exportOf(exec, scope, forUpdate = false) {
|
|
651
|
+
const suffix = forUpdate ? " FOR UPDATE" : "";
|
|
652
|
+
const tracked = await this.loadTracked(exec, scope, forUpdate);
|
|
653
|
+
const versions = await this.loadVersions(exec, scope, forUpdate);
|
|
654
|
+
const headers = await exec.query(this.q(`SELECT entry_id FROM file_history_boundary WHERE scope = ?${suffix}`, `SELECT entry_id FROM file_history_boundary WHERE scope = $1${suffix}`), [scope]);
|
|
652
655
|
if (tracked.length === 0 && headers.rows.length === 0)
|
|
653
656
|
return null;
|
|
654
657
|
await this.duringExportGraph(scope);
|
|
655
|
-
const files = await exec.query(this.q(
|
|
658
|
+
const files = await exec.query(this.q(`SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = ?${suffix}`, `SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = $1${suffix}`), [scope]);
|
|
656
659
|
const keyOfHash = new Map(tracked.map((t) => [t.keyHash, t.trackKey]));
|
|
657
660
|
const byEntry = new Map();
|
|
658
661
|
for (const row of files.rows) {
|
|
@@ -688,9 +691,9 @@ export class SqlFileHistoryStore {
|
|
|
688
691
|
async exportHistory(scope) {
|
|
689
692
|
const conn = await this.db.connect();
|
|
690
693
|
try {
|
|
691
|
-
await conn.
|
|
694
|
+
await conn.begin();
|
|
692
695
|
await this.lockScope(conn, scope);
|
|
693
|
-
const out = await this.exportOf(conn, scope);
|
|
696
|
+
const out = await this.exportOf(conn, scope, true);
|
|
694
697
|
await conn.commit();
|
|
695
698
|
return out;
|
|
696
699
|
}
|
|
@@ -743,10 +746,10 @@ export class SqlFileHistoryStore {
|
|
|
743
746
|
}
|
|
744
747
|
const conn = await this.db.connect();
|
|
745
748
|
try {
|
|
746
|
-
await conn.
|
|
749
|
+
await conn.begin();
|
|
747
750
|
await this.lockScope(conn, scope);
|
|
748
751
|
if (!(await this.scopeEmpty(conn, scope, true))) {
|
|
749
|
-
const now = await this.exportOf(conn, scope);
|
|
752
|
+
const now = await this.exportOf(conn, scope, true);
|
|
750
753
|
if (now !== null && fileHistoryExportDigest(now) === fileHistoryExportDigest(data)) {
|
|
751
754
|
await conn.commit();
|
|
752
755
|
return { ok: true };
|
|
@@ -139,8 +139,8 @@ export class SqlImageBake {
|
|
|
139
139
|
let inserted = false;
|
|
140
140
|
try {
|
|
141
141
|
if (this.db.dialect === "tidb") {
|
|
142
|
-
await conn.
|
|
143
|
-
await conn.query("INSERT
|
|
142
|
+
await conn.begin();
|
|
143
|
+
await conn.query("INSERT INTO image_bake_admit (pool) VALUES (?) ON DUPLICATE KEY UPDATE pool = pool", [this.poolName]);
|
|
144
144
|
await conn.query("SELECT pool FROM image_bake_admit WHERE pool = ? FOR UPDATE", [this.poolName]);
|
|
145
145
|
const busy = await conn.query("SELECT 1 FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 LIMIT 1 FOR UPDATE");
|
|
146
146
|
if (busy.rows.length === 0) {
|
|
@@ -36,8 +36,11 @@
|
|
|
36
36
|
* - reap 候删 = 事务外粗筛 + 事务内**逐盒锁定重验**(当前读 newest):窗内新到的消息救活盒,
|
|
37
37
|
* 绝不连带删信;盒行已被并发 drop = 跳过不计。
|
|
38
38
|
* - append 的 ensure 与 FOR UPDATE 之间被并发 drop 提交 = 同事务内重建一次(语义 = drop 后重建,
|
|
39
|
-
* seq 从 1)
|
|
40
|
-
*
|
|
39
|
+
* seq 从 1)。建盒动词 = 形 A(S-127)`ON DUPLICATE KEY UPDATE <pk> = <pk>`:重复键取 X 锁,与随后的
|
|
40
|
+
* `FOR UPDATE` **同锁模式**(`INSERT IGNORE` 取 S 再升 X = InnoDB 上的并发死锁形,B-013)。
|
|
41
|
+
* - 键长 > 190 字节写入即拒(fail-loud):截断键与全长查询参数字节比对恒 miss(分配读空行崩/隔离键
|
|
42
|
+
* 错位)。守卫在**语句之前**,与建行动词无关 —— 换成 ON DUPLICATE 之后超长键由引擎直接报错(严格
|
|
43
|
+
* 模式下 IGNORE 会把它降级成静默截断),方向=更响亮,守卫依旧是第一道。
|
|
41
44
|
*/
|
|
42
45
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
43
46
|
import type { Pool as PgPool } from "pg";
|
|
@@ -189,10 +189,10 @@ export class TiDBMailboxStore {
|
|
|
189
189
|
const hopChain = encodeHopChain(msg.hopChain);
|
|
190
190
|
const peerMeta = encodePeerMeta(msg.peerMeta);
|
|
191
191
|
return this.tx(async (c) => {
|
|
192
|
-
await c.query(`INSERT
|
|
192
|
+
await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE scope_key = scope_key`, [scope, handle, scope]);
|
|
193
193
|
let [rows] = (await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
|
|
194
194
|
if (rows.length === 0) {
|
|
195
|
-
await c.query(`INSERT
|
|
195
|
+
await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE scope_key = scope_key`, [scope, handle, scope]);
|
|
196
196
|
[rows] = (await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
|
|
197
197
|
}
|
|
198
198
|
const seq = Number(rows[0].next_seq);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FileDurableRulePartitionProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
|
|
2
2
|
import { type RuleImportTicket, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./permission-rule-store-sql.js";
|
|
3
3
|
import type { PermissionRuleStoreBundle } from "../rules-consent.js";
|
|
4
4
|
/**
|
|
@@ -84,9 +84,9 @@ export declare class FileRuleImportTicketStore {
|
|
|
84
84
|
* 那一条:过期只挡开始一次兑付,不挡做完一次已经开始的)。抢到之后**换一代认领**。 */
|
|
85
85
|
reclaim(ticketId: string, principal: string, purpose: RuleTicketPurpose, minClaimAgeMs: number): Promise<RuleTicketReclaimResult>;
|
|
86
86
|
}
|
|
87
|
-
/** local 车道的三面束 + 生命周期。`dispose()` 释放 core File provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
|
|
87
|
+
/** local 车道的三面束 + 生命周期。`dispose()` 释放 core File durable 分区 provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
|
|
88
88
|
export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
|
|
89
|
-
|
|
89
|
+
durable: FileDurableRulePartitionProvider;
|
|
90
90
|
approvals: FileRuleApprovalRecordStore;
|
|
91
91
|
tickets: FileRuleImportTicketStore;
|
|
92
92
|
dispose(): void;
|
|
@@ -94,7 +94,7 @@ export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
|
|
|
94
94
|
/**
|
|
95
95
|
* 装配 local 三面束。
|
|
96
96
|
*
|
|
97
|
-
* 🔴 `
|
|
97
|
+
* 🔴 `durable` 必须是**单例**(F1):core 的 `FileDurableRulePartitionProvider` 在第一次取写面时对规则
|
|
98
98
|
* 目录取一把进程级写锁,每次 `new` 一个就是第二个持有者 —— 而它对第二个持有者是**响亮拒绝**,不是
|
|
99
99
|
* 排队。所以束在 `LocalBackend` 上按字段持有,`LocalBackend.close()` 调 `dispose()` 释放
|
|
100
100
|
* (不释放 ⇒ 一次优雅重启会被自己上一条命留下的锁挡在门外,与数据根 `root/LOCK` 同一个病)。
|
|
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
|
-
import {
|
|
5
|
+
import { FileDurableRulePartitionProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
|
|
8
8
|
import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
@@ -341,18 +341,18 @@ export class FileRuleImportTicketStore {
|
|
|
341
341
|
export function createFilePermissionRuleStores(root, opts) {
|
|
342
342
|
const dir = join(root, "permission-rules");
|
|
343
343
|
ensureDir(dir);
|
|
344
|
-
const
|
|
344
|
+
const durable = opts?.onError ? new FileDurableRulePartitionProvider(dir, opts.onError) : new FileDurableRulePartitionProvider(dir);
|
|
345
345
|
const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
|
|
346
346
|
const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError, opts?.monotonic);
|
|
347
347
|
return {
|
|
348
|
-
|
|
348
|
+
durable,
|
|
349
349
|
approvals,
|
|
350
350
|
tickets,
|
|
351
351
|
countBuckets: async () => readdirSync(dir).filter((n) => n === "local-owner.json" || /^[0-9a-f]{64}\.json$/.test(n)).length,
|
|
352
352
|
dispose: () => {
|
|
353
353
|
approvals.close();
|
|
354
354
|
tickets.close();
|
|
355
|
-
|
|
355
|
+
durable.dispose();
|
|
356
356
|
},
|
|
357
357
|
};
|
|
358
358
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { type PermissionRuleWriter, type
|
|
2
|
+
import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
|
|
3
3
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
4
4
|
import type { PgQueryFn } from "./pg-query.js";
|
|
5
5
|
import { type SqlDriver } from "./sql-driver.js";
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* 一只 durable 分区的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
|
|
8
8
|
*
|
|
9
9
|
* 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
|
|
10
10
|
* 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
|
|
@@ -13,7 +13,7 @@ import { type SqlDriver } from "./sql-driver.js";
|
|
|
13
13
|
* 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
|
|
14
14
|
* 也是三份会各自漂的形状假设。
|
|
15
15
|
*/
|
|
16
|
-
export declare function writerOfSqlRuleStore(store:
|
|
16
|
+
export declare function writerOfSqlRuleStore(store: DurableRulePartition): PermissionRuleWriter | undefined;
|
|
17
17
|
/**
|
|
18
18
|
* 382P1(core 7.0.0 [ref] §4.3)—— 这是 **DURABLE 二员面**的 at-rest 形:`{global, project-with-root}`,
|
|
19
19
|
* 与 core `isValidDurableScope` 逐格等价(`test/permission-rule-durable-face-pin.test.ts` 探针表钉住)。
|
|
@@ -127,17 +127,20 @@ export declare function buildRuleBucketChecksum(body: {
|
|
|
127
127
|
observedVector?: RuleSyncFrontier;
|
|
128
128
|
}): string;
|
|
129
129
|
/**
|
|
130
|
-
* SQL 双方言的 {@link
|
|
130
|
+
* SQL 双方言的 {@link DurableRulePartitionProvider} —— [ref] 里的 **durable 分区后端**
|
|
131
|
+
* (`user` + `project` 两源的 OR-Set CRDT),**不是**引擎接缝本身:
|
|
132
|
+
* `RunnerDeps.permissionRuleStore` 是 `createPermissionRuleStoreProvider({ durable })` 合成出来的
|
|
133
|
+
* 统一店,合成点在 `main.ts` 一处(分区构成是**部署**决定,不是 backend 决定)。
|
|
131
134
|
*
|
|
132
|
-
* `forPrincipal(undefined)`
|
|
133
|
-
* —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
|
|
135
|
+
* `forPrincipal(undefined)` 恒解析成**零规则**分区(core 硬条款:未鉴权任务解析到零规则而不是一只
|
|
136
|
+
* 共享桶 —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
|
|
134
137
|
*/
|
|
135
|
-
export declare class
|
|
138
|
+
export declare class SqlDurableRulePartitionProvider implements DurableRulePartitionProvider {
|
|
136
139
|
private readonly db;
|
|
137
140
|
private readonly now;
|
|
138
141
|
constructor(db: SqlDriver, now?: () => number);
|
|
139
|
-
forPrincipal(principal: string | undefined):
|
|
140
|
-
forLocalOwner():
|
|
142
|
+
forPrincipal(principal: string | undefined): DurableRulePartition;
|
|
143
|
+
forLocalOwner(): DurableRulePartition;
|
|
141
144
|
}
|
|
142
145
|
/**
|
|
143
146
|
* durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed,
|
|
@@ -428,9 +431,10 @@ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
|
|
|
428
431
|
* 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
|
|
429
432
|
*/
|
|
430
433
|
export declare const RULE_REAP_BATCH = 500;
|
|
431
|
-
/** 三个 SQL
|
|
434
|
+
/** 三个 SQL 面的一次性装配束(one driver, three faces)。`durable` = [ref] 的 durable 分区后端;
|
|
435
|
+
* 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次。 */
|
|
432
436
|
export interface PermissionRuleStores {
|
|
433
|
-
|
|
437
|
+
durable: SqlDurableRulePartitionProvider;
|
|
434
438
|
approvals: SqlRuleApprovalRecordStore;
|
|
435
439
|
tickets: SqlRuleImportTicketStore;
|
|
436
440
|
/** [ref] §3 —— boot 期休眠行审计的**窄读口**:库里已有几只桶(`permission_rule` 一行一桶)。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
|
|
3
|
+
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
|
|
4
4
|
import { dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
5
5
|
import { isMissingColumnError } from "./sql-errors.js";
|
|
6
6
|
import { canonicalJson } from "../canonical-json.js";
|
|
@@ -332,7 +332,7 @@ function parseColumn(schema, raw, column, ownerKey) {
|
|
|
332
332
|
throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
|
|
333
333
|
return parsed.data;
|
|
334
334
|
}
|
|
335
|
-
class
|
|
335
|
+
class SqlDurableRulePartition {
|
|
336
336
|
db;
|
|
337
337
|
owner;
|
|
338
338
|
now;
|
|
@@ -490,7 +490,7 @@ class SqlPermissionRuleStore {
|
|
|
490
490
|
},
|
|
491
491
|
};
|
|
492
492
|
}
|
|
493
|
-
export class
|
|
493
|
+
export class SqlDurableRulePartitionProvider {
|
|
494
494
|
db;
|
|
495
495
|
now;
|
|
496
496
|
constructor(db, now = Date.now) {
|
|
@@ -499,17 +499,13 @@ export class SqlPermissionRuleStoreProvider {
|
|
|
499
499
|
}
|
|
500
500
|
forPrincipal(principal) {
|
|
501
501
|
if (typeof principal !== "string" || principal === "")
|
|
502
|
-
return
|
|
503
|
-
return new
|
|
502
|
+
return EMPTY_DURABLE_RULE_PARTITION;
|
|
503
|
+
return new SqlDurableRulePartition(this.db, { kind: "principal", principal }, this.now);
|
|
504
504
|
}
|
|
505
505
|
forLocalOwner() {
|
|
506
|
-
return new
|
|
506
|
+
return new SqlDurableRulePartition(this.db, { kind: "local-owner" }, this.now);
|
|
507
507
|
}
|
|
508
508
|
}
|
|
509
|
-
const ZERO_RULE_STORE = {
|
|
510
|
-
list: async () => ({ rules: [], tombstones: [], rev: 0 }),
|
|
511
|
-
durability: "process-local",
|
|
512
|
-
};
|
|
513
509
|
export class SqlRuleApprovalRecordStore {
|
|
514
510
|
db;
|
|
515
511
|
now;
|
|
@@ -744,7 +740,7 @@ export const RULE_REAP_BATCH = 500;
|
|
|
744
740
|
export function createSqlPermissionRuleStores(db, now) {
|
|
745
741
|
const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
|
|
746
742
|
return {
|
|
747
|
-
|
|
743
|
+
durable: new SqlDurableRulePartitionProvider(db, now),
|
|
748
744
|
approvals: new SqlRuleApprovalRecordStore(db, now),
|
|
749
745
|
tickets: new SqlRuleImportTicketStore(db),
|
|
750
746
|
reapExpired: async (nowMs) => {
|
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
*
|
|
16
16
|
* ── 方言差异台账(显式,永不藏进抽象;A12 判据)───────────────────────────────────────────────────
|
|
17
17
|
* - `?` 占位符 vs `$n`;
|
|
18
|
-
* -
|
|
18
|
+
* - 幂等建行(哨兵形 A,S-127):`ON DUPLICATE KEY UPDATE <pk> = <pk>` vs `ON CONFLICT (…) DO NOTHING`
|
|
19
|
+
* —— MySQL 侧刻意**不用** `INSERT IGNORE`:重复键的 IGNORE 取 S 锁,与随后的 `FOR UPDATE` 之间是
|
|
20
|
+
* S→X 升级,InnoDB 上两并发事务互等即 ER_LOCK_DEADLOCK(B-013);ON DUPLICATE 的重复键取的就是 X;
|
|
19
21
|
* - upsert:`ON DUPLICATE KEY UPDATE col = VALUES(col)` vs `ON CONFLICT (…) DO UPDATE SET col = EXCLUDED.col`;
|
|
20
22
|
* - 条件自增:`IF(cond, 0, 1)` vs `CASE WHEN cond THEN 0 ELSE 1 END`(本文件不用 —— 见 `acquire` 顶注:
|
|
21
23
|
* 自增判据在**应用层**算,因为它同时要产出返回给调用方的 token);
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
+
* - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} 的 `@contract txn.read-semantics`
|
|
25
|
+
* (本店唯一的读是 `acquire` 的租约行 `FOR UPDATE` —— 锁读在两引擎都是当前读)。
|
|
24
26
|
* - schema 属主:**四张表全部由车1 的 `retention-store-sql.ts` 建**(`TIDB_RETENTION_STATEMENTS` /
|
|
25
27
|
* `PG_RETENTION_SCHEMA`)。本文件一条 DDL 都不发 —— 「一张表恰好一个 DDL 属主」(db-gate.ts 顶注
|
|
26
28
|
* 的实测结论:第二个属主的 `IF NOT EXISTS` 会静默吞掉列/索引差异)。
|
|
@@ -122,8 +124,10 @@ export declare class SqlRetentionLaneStore implements RetentionLaneStore {
|
|
|
122
124
|
* **本 tick 零写静默跳过**。
|
|
123
125
|
*
|
|
124
126
|
* 事务三步,一步不能省:
|
|
125
|
-
* ①
|
|
126
|
-
*
|
|
127
|
+
* ① 幂等建一条 `expires_at_ms = 0` 的**哨兵行**(形 A,S-127:MySQL `ON DUPLICATE KEY UPDATE
|
|
128
|
+
* singleton = singleton` / PG `ON CONFLICT DO NOTHING`)—— 它永远不会改写一条既存租约(两方言的这
|
|
129
|
+
* 两个动词都只在缺行时写),且重复键取的是 **X** 锁,与 ② 的 `FOR UPDATE` 同锁模式(B-013:
|
|
130
|
+
* `INSERT IGNORE` 在这里取 S 再升 X,InnoDB 上两副本抢租互等直接死锁);
|
|
127
131
|
* ② `SELECT … FOR UPDATE` —— 行现在必定存在,锁真的拿得到;
|
|
128
132
|
* ③ 判 + `UPDATE`,同事务提交。
|
|
129
133
|
*
|
|
@@ -170,7 +174,7 @@ export declare class SqlRetentionLaneStore implements RetentionLaneStore {
|
|
|
170
174
|
* (车1 交接件②,逐字)。
|
|
171
175
|
*
|
|
172
176
|
* 🔴 为什么删行是**拆锁**而不是"解除":`retention_hold` 的行同时是**域级互斥哨兵** —— 车1 的三条破坏性
|
|
173
|
-
* 事务首步就是锁读这一行(
|
|
177
|
+
* 事务首步就是锁读这一行(ON DUPLICATE 补哨兵 → `SELECT … FOR UPDATE`),而 `FOR UPDATE`
|
|
174
178
|
* **锁不住一条不存在的行**(TiDB 无 gap lock;PG READ COMMITTED 同病)。删掉行 = 下一个 PUT 与一条
|
|
175
179
|
* 在飞的删除事务又可以同时读到"没有 hold",于是 operator 拿到成功回执之后数据仍被删 —— 那正是 §5 F2
|
|
176
180
|
* 要消灭的那件事。upsert 让两者抢**同一把行锁**,「PUT 提交完成 ⇒ 其后每个破坏性事务的首读必见
|
|
@@ -14,7 +14,7 @@ export class SqlRetentionLaneStore {
|
|
|
14
14
|
const conn = await this.db.connect();
|
|
15
15
|
try {
|
|
16
16
|
await conn.begin();
|
|
17
|
-
await conn.query(this.q(`INSERT
|
|
17
|
+
await conn.query(this.q(`INSERT INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0) ON DUPLICATE KEY UPDATE singleton = singleton`, `INSERT INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0) ON CONFLICT (singleton) DO NOTHING`));
|
|
18
18
|
const { rows } = await conn.query(this.q(`SELECT holder, fencing_token, expires_at_ms FROM ${RETENTION_LEASE_TABLE} WHERE singleton = 'x' FOR UPDATE`, `SELECT holder, fencing_token, expires_at_ms FROM ${RETENTION_LEASE_TABLE} WHERE singleton = 'x' FOR UPDATE`));
|
|
19
19
|
const cur = rows[0];
|
|
20
20
|
const curHolder = String(cur?.holder ?? "");
|
|
@@ -28,14 +28,15 @@
|
|
|
28
28
|
* ── 方言差异台账(显式,永不藏进抽象;A12 判据)───────────────────────────────────────────────────
|
|
29
29
|
* - `?` 占位符 vs `$n`;多值集合 MySQL 用 `IN (?,?,…)`、PG 用 `= ANY($n::text[])`(先例:
|
|
30
30
|
* workflow-run-store-sql 的 reap)
|
|
31
|
-
* -
|
|
31
|
+
* - 幂等建行:哨兵形(形 A,S-127)`ON DUPLICATE KEY UPDATE <pk> = <pk>` vs `ON CONFLICT (…) DO NOTHING`
|
|
32
|
+
* —— 随后有同行 `FOR UPDATE` 的建行**必须**用 ON DUPLICATE(重复键取 X,同锁模式);墓碑那种
|
|
33
|
+
* 「只读 affected 不加锁读」的写点(形 B)才保留 `INSERT IGNORE`
|
|
32
34
|
* - 级联 DELETE 的 JOIN 形:`DELETE te FROM a te JOIN b …` vs `DELETE FROM a te USING b …`
|
|
33
35
|
* - null-safe 比较:`<=>` vs `IS NOT DISTINCT FROM`(及其**占位符数量**差:`?` 不可复用)
|
|
34
36
|
* - JSON 提取(workflow_run 的 originatingSessionId):`JSON_UNQUOTE(JSON_EXTRACT(run,'$.x'))` vs
|
|
35
37
|
* `run::json->>'x'`
|
|
36
38
|
* - 表级 `COLLATE utf8mb4_bin` vs 逐列 `COLLATE "C"`(`isolation-key-collation` 门执法)
|
|
37
|
-
* -
|
|
38
|
-
* 普通 MySQL 上语法错;完整判据见 `SqlRetentionStore.tx` 的头注)
|
|
39
|
+
* - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} 的 `@contract txn.read-semantics`
|
|
39
40
|
* - schema 属主:MySQL DDL 由本文件导出、展开进 `tidb-pool.ts` 的 `SCHEMA_STATEMENTS`;PG DDL 由本文件的
|
|
40
41
|
* `ensurePgRetentionSchema` 导出、由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合(与 leader-run 同姿势)。
|
|
41
42
|
*
|
|
@@ -189,16 +190,9 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
189
190
|
params: unknown[];
|
|
190
191
|
};
|
|
191
192
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* (本机 MySQL 9.7 实测 `ER_PARSE_ERROR near 'PESSIMISTIC'`)—— 而发车门 2.6 的 canonical
|
|
196
|
-
* `TIDB_TEST_URL` 默认指向的正是一台**普通 MySQL**。用那条动词等于让本店在标准工作流上必红。
|
|
197
|
-
* · 语义上也不需要它:本店要的只是 `SELECT … FOR UPDATE` 是**当前读**。InnoDB 的加锁读在 RR 下本来
|
|
198
|
-
* 就读最新已提交版本;TiDB 自 3.0.8 起事务默认就是悲观模式,同样是当前读。显式动词只在「部署把
|
|
199
|
-
* TiDB 改回乐观模式」这一种配置下才多出保障,而那种部署会同时打破仓内其它 FOR UPDATE 腿
|
|
200
|
-
* (run-store 的 task_active 门、checkpoint 的 approval 门),不是本店能独自承担的假设。
|
|
201
|
-
* · PG:READ COMMITTED 逐语句取新快照,平凡 `BEGIN` 即所需。
|
|
193
|
+
* 事务壳。本店要的是「`SELECT … FOR UPDATE` 是**当前读**」—— 判据不在这里,在
|
|
194
|
+
* {@link SqlTxConn.begin} 的 `@contract txn.read-semantics`(隔离级与 TiDB 悲观模式是**连接的事实**,
|
|
195
|
+
* 每条池连接初始化时钉一次,设不上就拒启)。
|
|
202
196
|
*
|
|
203
197
|
* `protected` = 真库套件的语句录制注入点(判据「hold 锁读是事务首条 SQL」靠它自证)。
|
|
204
198
|
*/
|
|
@@ -213,8 +207,10 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
213
207
|
* 事务**首步**:拿本域的互斥行锁,并读出「这个域是不是冻结中」(§5 F2 承重)。
|
|
214
208
|
*
|
|
215
209
|
* 两步,缺一不可:
|
|
216
|
-
* ① `
|
|
217
|
-
*
|
|
210
|
+
* ① 幂等建一条 `held = 0` 的**哨兵行**(形 A,S-127:MySQL `ON DUPLICATE KEY UPDATE domain = domain` /
|
|
211
|
+
* PG `ON CONFLICT DO NOTHING`)—— 它永远不会改写一条既存的 hold(两方言的这两个动词都只在缺行时
|
|
212
|
+
* 写),只保证**接下来那把行锁有东西可锁**;重复键取的是 **X** 锁,与 ② 的 `FOR UPDATE` 同锁模式
|
|
213
|
+
* (`INSERT IGNORE` 在这里取 S 再升 X = InnoDB 上两个并发 sweep 互等的死锁形,B-013);
|
|
218
214
|
* ② `SELECT held … FOR UPDATE` —— 现在这行必定存在,于是锁真的拿到了。
|
|
219
215
|
*
|
|
220
216
|
* 🔴 为什么必须先造行(codex 交叉复审 R1-[high],验真后改):`FOR UPDATE` 锁不住缺席行(TiDB 无 gap
|
|
@@ -38,7 +38,7 @@ export const TIDB_RETENTION_STATEMENTS = [
|
|
|
38
38
|
-- 而 SELECT … FOR UPDATE **锁不住一条不存在的行**(TiDB 无 gap lock;PG 的 READ COMMITTED 同样
|
|
39
39
|
-- 不锁缺席行)。presence 形下两边都读到「没有 hold」是完全可能的,于是 operator 拿到成功回执之后
|
|
40
40
|
-- 数据仍被删 —— 那正是 F2 要消灭的那件事,只是换了个位置复发。
|
|
41
|
-
-- ⇒ 行**恒存在**(
|
|
41
|
+
-- ⇒ 行**恒存在**(破坏性事务首步幂等建一条 held=0 的哨兵),冻结与否写在这一列上。
|
|
42
42
|
-- 于是 hold 的放置(同一 PK 的 upsert)与三只破坏性事务抢的是**同一把行锁**,串行化由引擎给出:
|
|
43
43
|
-- PUT 提交完成 ⇒ 其后每一个破坏性事务的首读必见 held=1。放置/解除都必须走这一列(车2 交接件)。
|
|
44
44
|
held TINYINT(1) NOT NULL DEFAULT 0,
|
|
@@ -186,7 +186,7 @@ export class SqlRetentionStore {
|
|
|
186
186
|
}
|
|
187
187
|
async holdInForce(conn, domain) {
|
|
188
188
|
const key = domain;
|
|
189
|
-
await conn.query(this.q(`INSERT
|
|
189
|
+
await conn.query(this.q(`INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES (?,0,NULL,NULL,NULL) ON DUPLICATE KEY UPDATE domain = domain`, `INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES ($1,0,NULL,NULL,NULL) ON CONFLICT (domain) DO NOTHING`), [key]);
|
|
190
190
|
const { rows } = await conn.query(this.q(`SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = ? FOR UPDATE`, `SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = $1 FOR UPDATE`), [key]);
|
|
191
191
|
return Number(rows[0]?.held ?? 0) === 1;
|
|
192
192
|
}
|
|
@@ -367,6 +367,10 @@ export class SqlRetentionStore {
|
|
|
367
367
|
const s = set("owner_session_id", 1);
|
|
368
368
|
await conn.query(`DELETE FROM tool_result WHERE ${s.sql}`, s.params);
|
|
369
369
|
}
|
|
370
|
+
{
|
|
371
|
+
const s = set("scope", 1);
|
|
372
|
+
await conn.query(`SELECT scope FROM file_history_scope WHERE ${s.sql} ORDER BY scope FOR UPDATE`, s.params);
|
|
373
|
+
}
|
|
370
374
|
for (const [table, col] of [
|
|
371
375
|
["resume_anchor", "session_id"],
|
|
372
376
|
["approval_exemption", "session_id"],
|
|
@@ -29,8 +29,15 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
|
|
|
29
29
|
* Return shape aligned to core 1.423's optional interface seam (`Promise<void>`) — the affectedRows count was
|
|
30
30
|
* incidental and unconsumed; the integration test asserts the post-state (listBySession empty) instead. */
|
|
31
31
|
deleteBySession(sessionId: string): Promise<void>;
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
32
|
+
/**
|
|
33
|
+
* The store's one read-check-write transaction. Its load-bearing read is line ~106's
|
|
34
|
+
* `SELECT rules, rev … FOR UPDATE` — a LOCKING read, therefore a CURRENT read on both engines, which is
|
|
35
|
+
* what makes the loosen-diff decision see the prior it is diffing against. That "therefore" is not a
|
|
36
|
+
* property of the verb: it holds because {@link SqlTxConn.begin}'s `@contract txn.read-semantics` pins
|
|
37
|
+
* TiDB to pessimistic transactions at connection init (an optimistic session would read `FOR UPDATE` off
|
|
38
|
+
* the start-ts snapshot). Second line of defence, unchanged: the `rev` re-check plus the first write's
|
|
39
|
+
* PK race.
|
|
40
|
+
*/
|
|
34
41
|
private tx;
|
|
35
42
|
}
|
|
36
43
|
/** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
|