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).
116 lines
3.7 KiB
Go
116 lines
3.7 KiB
Go
package docgov
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"chat-agent-system/internal/app/uow"
|
|
)
|
|
|
|
func (e *GovernanceEngine) freezeReviewSnapshotLocked(ctx context.Context, doc *DocumentRecord) error {
|
|
if doc == nil {
|
|
return ErrDocumentNotFound
|
|
}
|
|
if e.pool != nil {
|
|
var head *int64
|
|
if err := e.pool.QueryRow(ctx, `SELECT head_snapshot_id FROM document_records WHERE doc_id=$1`, doc.DocID).Scan(&head); err == nil && head != nil && *head > 0 {
|
|
doc.ReviewSnapshotID = *head
|
|
}
|
|
if doc.ReviewSnapshotID <= 0 {
|
|
return nil
|
|
}
|
|
_, err := e.pool.Exec(ctx, `UPDATE document_records SET review_snapshot_id=$1 WHERE doc_id=$2`, doc.ReviewSnapshotID, doc.DocID)
|
|
return err
|
|
}
|
|
if doc.ReviewSnapshotID <= 0 {
|
|
doc.ReviewSnapshotID = 1
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LedgerMarkdown returns immutable snapshot markdown. It never reads workspace files.
|
|
func (e *GovernanceEngine) LedgerMarkdown(ctx context.Context, snapshotID int64) (string, error) {
|
|
if e.pool == nil || snapshotID <= 0 {
|
|
return "", fmt.Errorf("docgov: ledger snapshot unavailable")
|
|
}
|
|
var md string
|
|
err := e.pool.QueryRow(ctx, `
|
|
SELECT b.content_markdown
|
|
FROM document_snapshots s
|
|
JOIN document_content_blobs b ON b.content_sha256 = s.content_sha256
|
|
WHERE s.id = $1
|
|
`, snapshotID).Scan(&md)
|
|
return md, err
|
|
}
|
|
|
|
func (e *GovernanceEngine) persistApproveAndFAV(ctx context.Context, doc *DocumentRecord, audit *AuditLog) error {
|
|
if e.pool == nil {
|
|
return e.syncDocAndAuditToDB(ctx, doc, audit)
|
|
}
|
|
// Join the ambient unit of work when present. Opening a second
|
|
// connection here deadlocks against the caller's FOR UPDATE row lock on
|
|
// document_records (docflow Commands.run) — the UPDATE below blocks until
|
|
// statement timeout (SQLSTATE 57014). When a scope exists the uow owns
|
|
// the transaction boundary and commit; FAV rows join the same tx.
|
|
if scope, ok := uow.ScopeFrom(ctx); ok {
|
|
tx, err := uow.AsPG(scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := syncDocAndAuditTX(ctx, tx, doc, audit); err != nil {
|
|
return err
|
|
}
|
|
if e.OnFinalArtifactTX != nil {
|
|
md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID)
|
|
if err := e.OnFinalArtifactTX(ctx, tx, doc, []byte(md), doc.ReviewSnapshotID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if e.OnFinalArtifactTX == nil {
|
|
return e.syncDocAndAuditToDB(ctx, doc, audit)
|
|
}
|
|
tx, err := e.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
if err := syncDocAndAuditTX(ctx, tx, doc, audit); err != nil {
|
|
return err
|
|
}
|
|
md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID)
|
|
if err := e.OnFinalArtifactTX(ctx, tx, doc, []byte(md), doc.ReviewSnapshotID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func syncDocAndAuditTX(ctx context.Context, tx pgx.Tx, doc *DocumentRecord, audit *AuditLog) error {
|
|
_, err := tx.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET current_state = $1, is_locked = $2,
|
|
last_rejected_reason = $3, last_rejected_by_role = $4, last_rejected_by_name = $5, last_rejected_at = $6,
|
|
approved_at = $7, updated_at = $8, review_snapshot_id = NULLIF($9, 0)
|
|
WHERE doc_id = $10
|
|
`, string(doc.CurrentState), doc.IsLocked,
|
|
doc.LastRejectedReason, doc.LastRejectedByRole, doc.LastRejectedByName, doc.LastRejectedAt,
|
|
doc.ApprovedAt, doc.UpdatedAt, doc.ReviewSnapshotID, doc.DocID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if audit == nil {
|
|
return nil
|
|
}
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt)
|
|
return err
|
|
}
|