feat(db): add chat_turns idempotency table

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:21 +00:00
committed by weichen
co-authored by Cursor
parent 62ab734021
commit c887bc0480
4 changed files with 423 additions and 0 deletions
+23
View File
@@ -121,6 +121,29 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_category ON chat_messages(sessi
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_chat_messages_content_trgm ON chat_messages USING GIN (content gin_trgm_ops);
CREATE TABLE IF NOT EXISTS chat_turns (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
client_turn_id TEXT NOT NULL,
user_id BIGINT,
project_id BIGINT,
user_message_id BIGINT REFERENCES chat_messages(id) ON DELETE SET NULL,
assistant_message_id BIGINT REFERENCES chat_messages(id) ON DELETE SET NULL,
request_body_sha256 TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'accepted',
owner_token TEXT NOT NULL DEFAULT '',
lease_expires_at TIMESTAMPTZ,
first_request_id TEXT NOT NULL DEFAULT '',
last_request_id TEXT NOT NULL DEFAULT '',
attempt_count INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT uq_chat_turns_session_client UNIQUE (session_id, client_turn_id)
);
CREATE INDEX IF NOT EXISTS idx_chat_turns_session_status ON chat_turns(session_id, status);
CREATE INDEX IF NOT EXISTS idx_chat_turns_client ON chat_turns(client_turn_id);
-- 4.1 每日智能摘要表 (Daily Digests)
CREATE TABLE IF NOT EXISTS chat_daily_digests (
id BIGSERIAL PRIMARY KEY,
@@ -0,0 +1,24 @@
-- chat_turns: 端到端回合幂等权威表
CREATE TABLE IF NOT EXISTS chat_turns (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
client_turn_id TEXT NOT NULL,
user_id BIGINT,
project_id BIGINT,
user_message_id BIGINT REFERENCES chat_messages(id) ON DELETE SET NULL,
assistant_message_id BIGINT REFERENCES chat_messages(id) ON DELETE SET NULL,
request_body_sha256 TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'accepted',
owner_token TEXT NOT NULL DEFAULT '',
lease_expires_at TIMESTAMPTZ,
first_request_id TEXT NOT NULL DEFAULT '',
last_request_id TEXT NOT NULL DEFAULT '',
attempt_count INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT uq_chat_turns_session_client UNIQUE (session_id, client_turn_id)
);
CREATE INDEX IF NOT EXISTS idx_chat_turns_session_status ON chat_turns(session_id, status);
CREATE INDEX IF NOT EXISTS idx_chat_turns_client ON chat_turns(client_turn_id);
+294
View File
@@ -0,0 +1,294 @@
package ctxmgr
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
)
var (
ErrIdempotencyKeyReused = errors.New("idempotency key reused with different body")
ErrChatTurnNotFound = errors.New("chat turn not found")
)
// ChatTurnStatus 回合状态
type ChatTurnStatus string
const (
TurnStatusAccepted ChatTurnStatus = "accepted"
TurnStatusProcessing ChatTurnStatus = "processing"
TurnStatusCompleted ChatTurnStatus = "completed"
TurnStatusFailed ChatTurnStatus = "failed"
TurnStatusCancelled ChatTurnStatus = "cancelled"
)
// ChatTurnRecord 回合记录
type ChatTurnRecord struct {
ID int64
SessionID string
ClientTurnID string
UserID int64
ProjectID int64
UserMessageID int64
AssistantMessageID int64
RequestBodySHA256 string
Status ChatTurnStatus
OwnerToken string
FirstRequestID string
LastRequestID string
AttemptCount int
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt *time.Time
}
// AcquireTurnResult 取得执行权结果
type AcquireTurnResult struct {
Record *ChatTurnRecord
WonLease bool // true = 本请求赢得执行权,应跑 Agent
}
func HashTurnBody(sessionID, message string) string {
h := sha256.Sum256([]byte(sessionID + "\n" + message))
return hex.EncodeToString(h[:])
}
// AcquireChatTurn 尝试取得回合执行权(INSERT ON CONFLICT)
func (m *ContextManager) AcquireChatTurn(
ctx context.Context,
sessionID, clientTurnID, bodyHash, requestID string,
userID, projectID int64,
) (*AcquireTurnResult, error) {
if sessionID == "" || clientTurnID == "" {
return nil, errors.New("session_id and client_turn_id required")
}
if bodyHash == "" {
bodyHash = HashTurnBody(sessionID, clientTurnID)
}
if m.pool != nil {
return m.acquireChatTurnDB(ctx, sessionID, clientTurnID, bodyHash, requestID, userID, projectID)
}
return m.acquireChatTurnMemory(sessionID, clientTurnID, bodyHash, requestID, userID, projectID)
}
func (m *ContextManager) acquireChatTurnDB(
ctx context.Context,
sessionID, clientTurnID, bodyHash, requestID string,
userID, projectID int64,
) (*AcquireTurnResult, error) {
ownerToken := fmt.Sprintf("own_%d", time.Now().UnixNano())
leaseUntil := time.Now().Add(5 * time.Minute)
var existingID int64
var existingHash string
var existingStatus string
err := m.pool.QueryRow(ctx, `
SELECT id, request_body_sha256, status
FROM chat_turns
WHERE session_id = $1 AND client_turn_id = $2
`, sessionID, clientTurnID).Scan(&existingID, &existingHash, &existingStatus)
if err == nil {
if existingHash != "" && existingHash != bodyHash {
return nil, ErrIdempotencyKeyReused
}
rec, recErr := m.GetChatTurn(ctx, sessionID, clientTurnID)
if recErr != nil {
return nil, recErr
}
_, _ = m.pool.Exec(ctx, `
UPDATE chat_turns
SET attempt_count = attempt_count + 1,
last_request_id = $1,
updated_at = now()
WHERE id = $2
`, requestID, existingID)
rec.AttemptCount++
rec.LastRequestID = requestID
return &AcquireTurnResult{Record: rec, WonLease: false}, nil
}
var turnID int64
err = m.pool.QueryRow(ctx, `
INSERT INTO chat_turns (
session_id, client_turn_id, user_id, project_id,
request_body_sha256, status, owner_token, lease_expires_at,
first_request_id, last_request_id, attempt_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9, 1)
ON CONFLICT (session_id, client_turn_id) DO NOTHING
RETURNING id
`, sessionID, clientTurnID, userID, projectID, bodyHash, TurnStatusProcessing, ownerToken, leaseUntil, requestID).Scan(&turnID)
if err != nil {
// conflict race: re-read
rec, recErr := m.GetChatTurn(ctx, sessionID, clientTurnID)
if recErr != nil {
return nil, recErr
}
if rec.RequestBodySHA256 != bodyHash {
return nil, ErrIdempotencyKeyReused
}
return &AcquireTurnResult{Record: rec, WonLease: false}, nil
}
rec := &ChatTurnRecord{
ID: turnID,
SessionID: sessionID,
ClientTurnID: clientTurnID,
UserID: userID,
ProjectID: projectID,
RequestBodySHA256: bodyHash,
Status: TurnStatusProcessing,
OwnerToken: ownerToken,
FirstRequestID: requestID,
LastRequestID: requestID,
AttemptCount: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return &AcquireTurnResult{Record: rec, WonLease: true}, nil
}
var memTurnsMu sync.Mutex
var memTurns = map[string]*ChatTurnRecord{} // sessionID|clientTurnID -> record
func turnMemKey(sessionID, clientTurnID string) string {
return sessionID + "|" + clientTurnID
}
func (m *ContextManager) acquireChatTurnMemory(
sessionID, clientTurnID, bodyHash, requestID string,
userID, projectID int64,
) (*AcquireTurnResult, error) {
memTurnsMu.Lock()
defer memTurnsMu.Unlock()
key := turnMemKey(sessionID, clientTurnID)
if existing, ok := memTurns[key]; ok {
if existing.RequestBodySHA256 != bodyHash {
return nil, ErrIdempotencyKeyReused
}
existing.AttemptCount++
existing.LastRequestID = requestID
existing.UpdatedAt = time.Now()
return &AcquireTurnResult{Record: existing, WonLease: false}, nil
}
now := time.Now()
rec := &ChatTurnRecord{
ID: int64(len(memTurns) + 1),
SessionID: sessionID,
ClientTurnID: clientTurnID,
UserID: userID,
ProjectID: projectID,
RequestBodySHA256: bodyHash,
Status: TurnStatusProcessing,
OwnerToken: fmt.Sprintf("mem_%d", now.UnixNano()),
FirstRequestID: requestID,
LastRequestID: requestID,
AttemptCount: 1,
CreatedAt: now,
UpdatedAt: now,
}
memTurns[key] = rec
return &AcquireTurnResult{Record: rec, WonLease: true}, nil
}
// UpdateChatTurnUserMessage 绑定用户消息 ID
func (m *ContextManager) UpdateChatTurnUserMessage(ctx context.Context, sessionID, clientTurnID string, userMessageID int64) error {
if m.pool != nil {
_, err := m.pool.Exec(ctx, `
UPDATE chat_turns SET user_message_id = $1, updated_at = now()
WHERE session_id = $2 AND client_turn_id = $3
`, userMessageID, sessionID, clientTurnID)
return err
}
memTurnsMu.Lock()
defer memTurnsMu.Unlock()
if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok {
rec.UserMessageID = userMessageID
rec.UpdatedAt = time.Now()
}
return nil
}
// CompleteChatTurn 标记回合完成
func (m *ContextManager) CompleteChatTurn(ctx context.Context, sessionID, clientTurnID string, assistantMessageID int64) error {
now := time.Now()
if m.pool != nil {
_, err := m.pool.Exec(ctx, `
UPDATE chat_turns
SET status = $1, assistant_message_id = $2, completed_at = $3, updated_at = $3
WHERE session_id = $4 AND client_turn_id = $5
`, TurnStatusCompleted, assistantMessageID, now, sessionID, clientTurnID)
return err
}
memTurnsMu.Lock()
defer memTurnsMu.Unlock()
if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok {
rec.Status = TurnStatusCompleted
rec.AssistantMessageID = assistantMessageID
rec.CompletedAt = &now
rec.UpdatedAt = now
}
return nil
}
// FailChatTurn 标记回合失败
func (m *ContextManager) FailChatTurn(ctx context.Context, sessionID, clientTurnID string) error {
if m.pool != nil {
_, err := m.pool.Exec(ctx, `
UPDATE chat_turns SET status = $1, updated_at = now()
WHERE session_id = $2 AND client_turn_id = $3
`, TurnStatusFailed, sessionID, clientTurnID)
return err
}
memTurnsMu.Lock()
defer memTurnsMu.Unlock()
if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok {
rec.Status = TurnStatusFailed
rec.UpdatedAt = time.Now()
}
return nil
}
// GetChatTurn 查询回合
func (m *ContextManager) GetChatTurn(ctx context.Context, sessionID, clientTurnID string) (*ChatTurnRecord, error) {
if m.pool != nil {
var rec ChatTurnRecord
var completedAt *time.Time
err := m.pool.QueryRow(ctx, `
SELECT id, session_id, client_turn_id,
COALESCE(user_id, 0), COALESCE(project_id, 0),
COALESCE(user_message_id, 0), COALESCE(assistant_message_id, 0),
request_body_sha256, status,
COALESCE(first_request_id, ''), COALESCE(last_request_id, ''),
attempt_count, created_at, updated_at, completed_at
FROM chat_turns
WHERE session_id = $1 AND client_turn_id = $2
`, sessionID, clientTurnID).Scan(
&rec.ID, &rec.SessionID, &rec.ClientTurnID,
&rec.UserID, &rec.ProjectID,
&rec.UserMessageID, &rec.AssistantMessageID,
&rec.RequestBodySHA256, &rec.Status,
&rec.FirstRequestID, &rec.LastRequestID,
&rec.AttemptCount, &rec.CreatedAt, &rec.UpdatedAt, &completedAt,
)
if err != nil {
return nil, ErrChatTurnNotFound
}
rec.CompletedAt = completedAt
return &rec, nil
}
memTurnsMu.Lock()
defer memTurnsMu.Unlock()
rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]
if !ok {
return nil, ErrChatTurnNotFound
}
cp := *rec
return &cp, nil
}
+82
View File
@@ -0,0 +1,82 @@
package ctxmgr
import (
"context"
"sync"
"testing"
)
func TestAcquireChatTurn_DuplicateSameBody(t *testing.T) {
memTurns = map[string]*ChatTurnRecord{}
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
sessionID := "sess-dup"
turnID := "turn-abc"
bodyHash := HashTurnBody(sessionID, "hello world")
acq1, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, bodyHash, "req-1", 1, 1)
if err != nil || !acq1.WonLease {
t.Fatalf("first acquire should win lease: %v %+v", err, acq1)
}
acq2, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, bodyHash, "req-2", 1, 1)
if err != nil {
t.Fatalf("second acquire err: %v", err)
}
if acq2.WonLease {
t.Fatal("second acquire must not win lease")
}
if acq2.Record.AttemptCount < 2 {
t.Fatalf("attempt_count should increment, got %d", acq2.Record.AttemptCount)
}
}
func TestAcquireChatTurn_ConcurrentDuplicate(t *testing.T) {
memTurns = map[string]*ChatTurnRecord{}
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
sessionID := "sess-conc"
turnID := "turn-conc"
bodyHash := HashTurnBody(sessionID, "concurrent msg")
var wg sync.WaitGroup
wins := 0
var mu sync.Mutex
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
acq, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, bodyHash, "req", 1, 1)
if err != nil {
t.Errorf("acquire %d: %v", n, err)
return
}
if acq.WonLease {
mu.Lock()
wins++
mu.Unlock()
}
}(i)
}
wg.Wait()
if wins != 1 {
t.Fatalf("expected exactly 1 winner, got %d", wins)
}
}
func TestAcquireChatTurn_DifferentBodyRejected(t *testing.T) {
memTurns = map[string]*ChatTurnRecord{}
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
sessionID := "sess-reuse"
turnID := "turn-x"
_, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, HashTurnBody(sessionID, "msg-a"), "req-1", 1, 1)
if err != nil {
t.Fatal(err)
}
_, err = mgr.AcquireChatTurn(ctx, sessionID, turnID, HashTurnBody(sessionID, "msg-b"), "req-2", 1, 1)
if err != ErrIdempotencyKeyReused {
t.Fatalf("expected ErrIdempotencyKeyReused, got %v", err)
}
}