docflow Commands.run takes SELECT ... FOR UPDATE on document_records inside its unit of work before calling ApproverApprove; persistApproveAndFAV then opened a second pool connection and its UPDATE blocked on that row lock until statement timeout (SQLSTATE 57014), failing every final approval. Join the ambient uow scope (doc sync + FAV staging on the caller tx, commit owned by the uow); only open an own tx when no scope is present. Regression test reproduces the production shape (FOR UPDATE + FAV wiring inside Within).
163 lines
6.6 KiB
Go
163 lines
6.6 KiB
Go
package docgov_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/app/uow"
|
|
"chat-agent-system/internal/artifactvault"
|
|
"chat-agent-system/internal/docgov"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
func artifactvaultIngest() *artifactvault.Ingest { return &artifactvault.Ingest{Env: "test"} }
|
|
|
|
// Regression (2026-09-26): loadDocumentFromDBLocked did not select
|
|
// review_snapshot_id, so any doc loaded from PG after a restart (or cache
|
|
// miss) lost the frozen review snapshot and ApproverApprove failed with
|
|
// "approved requires frozen review_snapshot_id". Freeze via SubmitForReview,
|
|
// then re-load through a fresh engine and assert the snapshot id survives.
|
|
func TestDocGov_LoadDocumentCarriesReviewSnapshotID(t *testing.T) {
|
|
pool := openIsolatedPool(t)
|
|
defer pool.Close()
|
|
projectID := seedTestProject(t, pool)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
engine := docgov.NewGovernanceEngine(pool)
|
|
testDocID := fmt.Sprintf("doc_test_snap_%d", time.Now().UnixNano())
|
|
|
|
doc, err := engine.CreateOrGetDocument(ctx, testDocID, projectID, "feasibility_reg",
|
|
"快照装载回归", "V0.5_feasibility", "data/dev/workspaces/test_snap",
|
|
2, "张工", 3, "李主管", 1, "Seed Admin")
|
|
if err != nil {
|
|
t.Fatalf("create doc: %v", err)
|
|
}
|
|
if _, err := engine.RecordDocumentViewed(ctx, testDocID, 2, docgov.RoleAuthor, "张工"); err != nil {
|
|
t.Fatalf("claim author: %v", err)
|
|
}
|
|
if err := engine.MarkDocumentContentUpdated(ctx, testDocID, "snap_sha256", false); err != nil {
|
|
t.Fatalf("mark content: %v", err)
|
|
}
|
|
if _, err := engine.SubmitForReview(ctx, testDocID, 2, docgov.RoleAuthor, "张工"); err != nil {
|
|
t.Fatalf("submit review: %v", err)
|
|
}
|
|
// Seed a frozen snapshot id directly: freezeReviewSnapshotLocked copies
|
|
// head_snapshot_id which only exists once the ledger sealed a snapshot;
|
|
// the regression under test is the load path, not the freeze path.
|
|
var want int64
|
|
err = pool.QueryRow(ctx, `
|
|
WITH blob AS (
|
|
INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size, created_at)
|
|
VALUES ($2, '# regression', 12, now())
|
|
RETURNING content_sha256
|
|
)
|
|
INSERT INTO document_snapshots (doc_id, content_sha256, actor_user_id, actor_snapshot, source_kind, created_at)
|
|
SELECT $1::text, blob.content_sha256, 2, '"张工"'::jsonb, 'human', now() FROM blob
|
|
RETURNING id
|
|
`, testDocID, fmt.Sprintf("snap_load_sha_%d", time.Now().UnixNano())).Scan(&want)
|
|
if err != nil || want <= 0 {
|
|
t.Fatalf("seed snapshot: id=%d err=%v", want, err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `UPDATE document_records SET review_snapshot_id=$1 WHERE doc_id=$2`, want, testDocID); err != nil {
|
|
t.Fatalf("set snapshot id: %v", err)
|
|
}
|
|
|
|
// Fresh engine simulates a restart: the doc must load with its snapshot.
|
|
fresh := docgov.NewGovernanceEngine(pool)
|
|
loaded, err := fresh.GetDocument(ctx, testDocID)
|
|
if err != nil {
|
|
t.Fatalf("fresh engine load: %v", err)
|
|
}
|
|
if loaded.ReviewSnapshotID != want {
|
|
t.Fatalf("ReviewSnapshotID lost on load: got %d want %d", loaded.ReviewSnapshotID, want)
|
|
}
|
|
_ = doc
|
|
}
|
|
|
|
// Full approve chain against an isolated PG with production-shaped FAV
|
|
// wiring: surfaces the true persistApproveAndFAV failure that the HTTP
|
|
// envelope redacts.
|
|
func TestDocGov_ApproveChainWithFAVWiring(t *testing.T) {
|
|
pool := openIsolatedPool(t)
|
|
defer pool.Close()
|
|
projectID := seedTestProject(t, pool)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
|
|
defer cancel()
|
|
|
|
ing := artifactvaultIngest()
|
|
engine := docgov.NewGovernanceEngine(pool)
|
|
engine.OnFinalArtifactTX = func(c context.Context, tx pgx.Tx, d *docgov.DocumentRecord, md []byte, snapID int64) error {
|
|
return ing.ApprovedTX(c, tx, d.ProjectID, d.DocID, snapID, d.ApprovedAt, md)
|
|
}
|
|
|
|
testDocID := fmt.Sprintf("doc_test_fav_%d", time.Now().UnixNano())
|
|
if _, err := engine.CreateOrGetDocument(ctx, testDocID, projectID, "feasibility_reg",
|
|
"FAV 接线批准链", "V0.5_feasibility", "data/dev/workspaces/test_fav",
|
|
2, "张工", 3, "李主管", 1, "Seed Admin"); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if _, err := engine.RecordDocumentViewed(ctx, testDocID, 2, docgov.RoleAuthor, "张工"); err != nil {
|
|
t.Fatalf("claim: %v", err)
|
|
}
|
|
if err := engine.MarkDocumentContentUpdated(ctx, testDocID, "fav_sha256", false); err != nil {
|
|
t.Fatalf("mark: %v", err)
|
|
}
|
|
if _, err := engine.SubmitForReview(ctx, testDocID, 2, docgov.RoleAuthor, "张工"); err != nil {
|
|
t.Fatalf("submit: %v", err)
|
|
}
|
|
if _, err := engine.ReviewApprove(ctx, testDocID, 3, docgov.RoleReviewer, "李主管", ""); err != nil {
|
|
t.Fatalf("review approve: %v", err)
|
|
}
|
|
// Freeze a real snapshot like the sealed ledger does in production.
|
|
var snapID int64
|
|
err := pool.QueryRow(ctx, `
|
|
WITH blob AS (
|
|
INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size, created_at)
|
|
VALUES ($2, '# fav chain', 12, now())
|
|
RETURNING content_sha256
|
|
)
|
|
INSERT INTO document_snapshots (doc_id, content_sha256, actor_user_id, actor_snapshot, source_kind, created_at)
|
|
SELECT $1::text, blob.content_sha256, 2, '"张工"'::jsonb, 'human', now() FROM blob
|
|
RETURNING id
|
|
`, testDocID, fmt.Sprintf("fav_chain_sha_%d", time.Now().UnixNano())).Scan(&snapID)
|
|
if err != nil || snapID <= 0 {
|
|
t.Fatalf("seed snapshot: %d %v", snapID, err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `UPDATE document_records SET review_snapshot_id=$1, head_snapshot_id=$1 WHERE doc_id=$2`, snapID, testDocID); err != nil {
|
|
t.Fatalf("freeze: %v", err)
|
|
}
|
|
// Reload through a fresh engine so the approve path loads snapshot ids from PG.
|
|
engine = docgov.NewGovernanceEngine(pool)
|
|
engine.OnFinalArtifactTX = func(c context.Context, tx pgx.Tx, d *docgov.DocumentRecord, md []byte, snap int64) error {
|
|
return ing.ApprovedTX(c, tx, d.ProjectID, d.DocID, snap, d.ApprovedAt, md)
|
|
}
|
|
// Production shape: docflow Commands.run wraps the approve in a unit of
|
|
// work and takes SELECT ... FOR UPDATE on document_records first. The old
|
|
// persistApproveAndFAV opened a second connection and deadlocked on that
|
|
// row lock until statement timeout.
|
|
unit := uow.NewPG(pool)
|
|
err = unit.Within(ctx, func(scope uow.TxScope) error {
|
|
if _, err := (docgov.Repository{}).GetForUpdate(uow.WithScope(ctx, scope), scope, testDocID); err != nil {
|
|
return err
|
|
}
|
|
_, err := engine.ApproverApprove(uow.WithScope(ctx, scope), testDocID, 1, docgov.RoleApprover, "Seed Admin", "ok")
|
|
return err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("approver approve inside uow (deadlock regression): %v", err)
|
|
}
|
|
got, err := engine.GetDocument(ctx, testDocID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.CurrentState != docgov.StateApproved {
|
|
t.Fatalf("state = %s, want approved", got.CurrentState)
|
|
}
|
|
}
|