Files
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

330 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// 协作存储 U5(KTD4/KTD5):会话执行租约 + 持久命令收件箱。
//
// 租约:conversation_executions 一行一会话。TryClaimExecution 空闲/租约到期
// 才可占用;接管(含同实例重新占用)递增 epoch——旧 owner 的续租与结算按
//(instance, epoch)双重校验被拒(fencing)。lease_until 用应用侧 UTC ISO
// (单库写入方一致即可;跨库时钟偏移属部署合同,见 runbook)。
//
// 命令:run_commands 收件箱承载跨实例取消/HITL(KTD5)。command_id 幂等;
// pending→consumed 由消费方原子认领(PG FOR UPDATE SKIP LOCKED;SQLite 单写
// 者条件更新)。命令不删除——审计与重放判定保留。
// ErrExecutionBusy 会话执行权被其他实例持有且租约未到期。
var ErrExecutionBusy = errors.New("store: execution owned by another instance")
// ErrFenced 旧执行代次的写入被围栏拒绝(epoch 已被接管递增)。
var ErrFenced = errors.New("store: fenced by newer execution epoch")
// ExecutionState 当前执行租约快照(诊断/测试)。
type ExecutionState struct {
ConversationID string
InstanceID string
RunID string
Epoch int64
LeaseUntil string
}
// TryClaimExecution 占用(或租约到期后接管)会话执行权。
// 返回接管后的 epoch(接管必然 +1)。被他人持有且未到期 → ErrExecutionBusy。
func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leaseSec int) (int64, error) {
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if err := s.lockAdmission(tx); err != nil { // 复用全局账本锁串行化清点
return 0, err
}
epoch, err := s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec)
if err != nil {
return epoch, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return epoch, nil
}
// TryClaimExecutionAccountChecked locks the actor before the admission ledger
// and verifies the conversation/run owner in the same final write transaction.
func (s *Store) TryClaimExecutionAccountChecked(actor, conversationID, instanceID, runID string, leaseSec int, allowImplicitDefault bool) (int64, error) {
var epoch int64
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := s.lockAdmission(tx); err != nil {
return err
}
var owner string
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if owner != actor {
return ErrNotFound
}
if runID != "" {
var one int
if err := tx.QueryRow(s.q(`SELECT 1 FROM runs WHERE id=? AND owner=? AND conversation_id=?`), runID, actor, conversationID).Scan(&one); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
}
var err error
epoch, err = s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec)
return err
})
return epoch, err
}
func (s *Store) claimExecutionTx(tx *sql.Tx, conversationID, instanceID, runID string, leaseSec int) (int64, error) {
if leaseSec <= 0 {
leaseSec = 90
}
now := time.Now().UTC()
until := now.Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
var inst, lease string
var epoch int64
err := tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.Exec(s.q(`INSERT INTO conversation_executions (conversation_id, instance_id, run_id, epoch, lease_until, updated_at) VALUES (?,?,?,1,?,?)`),
conversationID, instanceID, runID, until, nowISO()); err != nil {
return 0, err
}
return 1, nil
case err != nil:
return 0, err
}
if inst != instanceID && lease > nowISO() {
return epoch, fmt.Errorf("conv %s: %w", conversationID, ErrExecutionBusy)
}
// 接管(到期或同实例重占):epoch+1,fencing 由此产生。
if _, err := tx.Exec(s.q(`UPDATE conversation_executions SET instance_id=?, run_id=?, epoch=epoch+1, lease_until=?, updated_at=? WHERE conversation_id=?`),
instanceID, runID, until, nowISO(), conversationID); err != nil {
return 0, err
}
return epoch + 1, nil
}
// RenewExecution 续租(fencing:instance+epoch 双校验)。假 = 已被接管或释放。
func (s *Store) RenewExecution(conversationID, instanceID string, epoch int64, leaseSec int) bool {
if leaseSec <= 0 {
leaseSec = 90
}
until := time.Now().UTC().Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
res, err := s.db.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
until, nowISO(), conversationID, instanceID, epoch)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
// RenewExecutionAccountChecked only extends the lease while the conversation
// owner is effective in the same transaction as the conditional UPDATE.
func (s *Store) RenewExecutionAccountChecked(actor, conversationID, instanceID string, epoch int64, leaseSec int, allowImplicitDefault bool) bool {
if leaseSec <= 0 {
leaseSec = 90
}
until := time.Now().UTC().Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
updated := false
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var owner string
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if owner != actor {
return ErrNotFound
}
res, err := tx.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
until, nowISO(), conversationID, instanceID, epoch)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
updated = n == 1
return nil
})
return err == nil && updated
}
// ReleaseExecution 主动释放(优雅停机):行保留、租约置为已过期——epoch 保留,
// 下一次占用仍递增(fencing 单调)。
func (s *Store) ReleaseExecution(conversationID, instanceID string, epoch int64) {
_, _ = s.db.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
time.Now().UTC().Add(-time.Second).UTC().Format(time.RFC3339Nano), nowISO(), conversationID, instanceID, epoch)
}
// GetExecution 读取当前租约(无行 = 从未占用)。
func (s *Store) GetExecution(conversationID string) (ExecutionState, error) {
var e ExecutionState
err := s.db.QueryRow(s.q(`SELECT conversation_id, instance_id, COALESCE(run_id,''), epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).
Scan(&e.ConversationID, &e.InstanceID, &e.RunID, &e.Epoch, &e.LeaseUntil)
if errors.Is(err, sql.ErrNoRows) {
return e, ErrNotFound
}
return e, err
}
// ExecutionFenceOK 校验 (instance, epoch) 仍是当前执行权(结算/事件写入的
// 前置检查;事务内校验见 SettleRunFenced)。
func (s *Store) ExecutionFenceOK(conversationID, instanceID string, epoch int64) bool {
var n int
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM conversation_executions WHERE conversation_id=? AND instance_id=? AND epoch=?`), conversationID, instanceID, epoch).Scan(&n); err != nil {
return false
}
return n > 0
}
// SettleRunFenced 带执行围栏的终态结算(KTD4:旧 owner 恢复后不能提交)。
// fenced=true 表示 (instance,epoch) 已不是当前执行权——run 未被改动,调用方
// 应将本地 run 视为被接管(不写终态、事件标注接管)。无租约行(SQLite 本地
// 档/未启用执行权)恒放行。
// RunCommand 收件箱命令。
type RunCommand struct {
CommandID string
Kind string // cancel | hitl
RunID string
ConversationID string
Epoch int64
Payload string
CreatedAt string
}
// EnqueueCommandInTx 与业务提交同事务投递命令(KTD5/U5 余项:outbox——
// 批准数据与待投递命令原子提交)。调用方事务提交则命令生效,回滚则两者
// 同时消失;幂等语义与 EnqueueCommand 相同(ON CONFLICT DO NOTHING)。
func (s *Store) EnqueueCommandInTx(tx *sql.Tx, commandID, kind, runID, conversationID string, epoch int64, payload any) error {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = tx.Exec(s.q(`INSERT INTO run_commands (command_id, kind, run_id, conversation_id, epoch, payload, state, created_at)
VALUES (?,?,?,?,?,?,'pending',?)
ON CONFLICT(command_id) DO NOTHING`),
commandID, kind, runID, conversationID, epoch, string(raw), nowISO())
if err != nil {
return fmt.Errorf("enqueue command %s (tx): %w", commandID, err)
}
return nil
}
// EnqueueCommand 投递跨实例命令(幂等:同 command_id 重复投递不产生新行)。
func (s *Store) EnqueueCommand(commandID, kind, runID, conversationID string, epoch int64, payload any) error {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`INSERT INTO run_commands (command_id, kind, run_id, conversation_id, epoch, payload, state, created_at)
VALUES (?,?,?,?,?,?,'pending',?)
ON CONFLICT(command_id) DO NOTHING`),
commandID, kind, runID, conversationID, epoch, string(raw), nowISO())
if err != nil {
return fmt.Errorf("enqueue command %s: %w", commandID, err)
}
return nil
}
// EnqueueCommandAccountChecked resolves the run owner and conversation from
// persisted rows in the final account-guarded transaction. A client-supplied
// run ID can locate a command target but cannot authorize another owner's run.
func (s *Store) EnqueueCommandAccountChecked(actor, commandID, kind, runID string, epoch int64, payload any, allowImplicitDefault bool) error {
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var owner, conversationID string
query := s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
if err := tx.QueryRow(query, runID).Scan(&owner, &conversationID); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if owner != actor {
return ErrNotFound
}
return s.EnqueueCommandInTx(tx, commandID, kind, runID, conversationID, epoch, payload)
})
}
// ClaimPendingCommands 认领待处理命令(原子 pending→consumed)。
// PG:FOR UPDATE SKIP LOCKED(多实例并发认领不重复);SQLite:单写者条件更新。
// 只认领「本实例相关」的命令由调用方过滤——收件箱按执行权路由:命令在
// 被执行实例消费(cancel/hitl 目标 run 的当前 owner)。
func (s *Store) ClaimPendingCommands(consumer string, limit int) ([]RunCommand, error) {
if limit <= 0 {
limit = 16
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var rows *sql.Rows
if s.dialect.name() == "postgres" {
rows, err = tx.Query(`SELECT command_id, kind, run_id, conversation_id, epoch, payload, created_at
FROM run_commands WHERE state='pending' ORDER BY created_at
LIMIT $1 FOR UPDATE SKIP LOCKED`, limit)
} else {
rows, err = tx.Query(s.q(`SELECT command_id, kind, run_id, conversation_id, epoch, payload, created_at
FROM run_commands WHERE state='pending' ORDER BY created_at LIMIT ?`), limit)
}
if err != nil {
return nil, err
}
var out []RunCommand
for rows.Next() {
var c RunCommand
if err := rows.Scan(&c.CommandID, &c.Kind, &c.RunID, &c.ConversationID, &c.Epoch, &c.Payload, &c.CreatedAt); err != nil {
rows.Close()
return nil, err
}
out = append(out, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, c := range out {
res, err := tx.Exec(s.q(`UPDATE run_commands SET state='consumed', consumed_at=?, consumer=? WHERE command_id=? AND state='pending'`),
nowISO(), consumer, c.CommandID)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, fmt.Errorf("command %s claimed concurrently", c.CommandID)
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return out, nil
}
// CommandState 查询单条命令状态(测试/诊断)。
func (s *Store) CommandState(commandID string) (state, consumer string, err error) {
err = s.db.QueryRow(s.q(`SELECT state, COALESCE(consumer,'') FROM run_commands WHERE command_id=?`), commandID).Scan(&state, &consumer)
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
}
return
}