mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
d6dcc2d89b
* fix(store): decision last_error is sanitised and cut at a rune boundary (BUG-4018)
A provider response body is arbitrary bytes. The byte cut at 1024 could split
a rune, and invalid UTF-8 or a NUL made the last_error UPDATE fail on
Postgres (SQLSTATE 22021 / the NUL guard), leaving the failure unrecorded.
truncateDecisionError now repairs NULs, replaces invalid UTF-8, then cuts at
a rune start. Corrects the decision_jobs.last_error NUL exemption reason.
Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
* test(store): exact boundary cuts and sanitise-before-cut ordering for decision errors (BUG-4018 r1)
Boundary test asserts the exact cut for 2-, 3- and 4-byte runes; new
FailDecisionJob subtests pin sanitise-then-cut. nulcolumns reason says
last_error may embed the provider body.
Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
* test(store): ASCII just over the decision-error limit is cut to exactly 1024 bytes (BUG-4018 r2)
Every boundary case carried a rune of width >= 2, so the shortest
over-limit input was 1026 bytes and a limit check off by one byte
(len(s) <= max+1) passed. 1025 and 2000 plain ASCII bytes now pin the cut.
Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
* test(store): the off-by-one limit comment states what was measured (BUG-4018 r2)
The previous commit's comment said no earlier boundary case could catch a
limit check off by one byte. Measured at 852e0f9c7, the pad=1021 case with
a 2-byte rune (1025 bytes) already does. The ASCII cases stay, as the
direct pin on the limit check, and the comment now says both. Comment only.
Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
571 lines
22 KiB
Go
571 lines
22 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Typed-decision queue and storage (TASK-3117). Runs on both backends via
|
|
// testStore (PAD_TEST_POSTGRES_URL selects Postgres).
|
|
|
|
func decisionFixture(t *testing.T) (*Store, *models.Workspace, *models.Collection) {
|
|
t.Helper()
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Decisions")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
return s, ws, col
|
|
}
|
|
|
|
func countDecisionJobs(t *testing.T, s *Store) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM decision_jobs`).Scan(&n); err != nil {
|
|
t.Fatalf("count decision_jobs: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func mustJob(t *testing.T, s *Store, itemID, set string) *DecisionJobState {
|
|
t.Helper()
|
|
j, err := s.GetDecisionJob(itemID, set)
|
|
if err != nil {
|
|
t.Fatalf("get job: %v", err)
|
|
}
|
|
if j == nil {
|
|
t.Fatalf("no decision job owed for %s/%s", itemID, set)
|
|
}
|
|
return j
|
|
}
|
|
|
|
// The nil-provider case: no resolver installed means every door writes nothing.
|
|
// The doors are exercised for real, so a pass is not a door that never ran.
|
|
func TestDecisionJobs_NoResolverEnqueuesNothing(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Quiet", "body")
|
|
title := "Quiet, renamed"
|
|
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{Title: &title}); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if _, err := s.CreateComment(ws.ID, item.ID, "", models.CommentCreate{Author: "a", Body: "hi"}); err != nil {
|
|
t.Fatalf("comment: %v", err)
|
|
}
|
|
if n := countDecisionJobs(t, s); n != 0 {
|
|
t.Fatalf("decision_jobs has %d rows with no resolver installed; want 0", n)
|
|
}
|
|
|
|
// Positive control on the same fixture: installing a resolver makes the
|
|
// very next write enqueue, so the zero above is about the resolver.
|
|
s.SetDecisionSetResolver(func(string) []string { return []string{"triage"} })
|
|
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{Title: &title}); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if n := countDecisionJobs(t, s); n != 1 {
|
|
t.Fatalf("after installing a resolver, decision_jobs has %d rows; want 1", n)
|
|
}
|
|
}
|
|
|
|
// Each of the three doors enqueues, and repeats coalesce into ONE row whose
|
|
// generation counts the writes.
|
|
func TestDecisionJobs_EachDoorEnqueuesAndCoalesces(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(slug string) []string {
|
|
if slug != col.Slug {
|
|
t.Errorf("resolver got collection slug %q, want %q", slug, col.Slug)
|
|
}
|
|
return []string{"triage"}
|
|
})
|
|
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Door", "body")
|
|
if g := mustJob(t, s, item.ID, "triage").Generation; g != 1 {
|
|
t.Fatalf("after create, generation = %d; want 1", g)
|
|
}
|
|
|
|
title := "Door 2"
|
|
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{Title: &title}); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if g := mustJob(t, s, item.ID, "triage").Generation; g != 2 {
|
|
t.Fatalf("after update, generation = %d; want 2", g)
|
|
}
|
|
|
|
if _, err := s.CreateComment(ws.ID, item.ID, "", models.CommentCreate{Author: "a", Body: "note"}); err != nil {
|
|
t.Fatalf("comment: %v", err)
|
|
}
|
|
if g := mustJob(t, s, item.ID, "triage").Generation; g != 3 {
|
|
t.Fatalf("after comment, generation = %d; want 3", g)
|
|
}
|
|
if n := countDecisionJobs(t, s); n != 1 {
|
|
t.Fatalf("three writes left %d job rows; want 1 (coalesced)", n)
|
|
}
|
|
}
|
|
|
|
func TestDecisionJobs_ResolverScopesByCollection(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
other := createTestCollection(t, s, ws.ID, "Ideas")
|
|
s.SetDecisionSetResolver(func(slug string) []string {
|
|
if slug == col.Slug {
|
|
return []string{"triage"}
|
|
}
|
|
return nil
|
|
})
|
|
createTestItem(t, s, ws.ID, other.ID, "Out of scope", "")
|
|
if n := countDecisionJobs(t, s); n != 0 {
|
|
t.Fatalf("an item in an unscoped collection enqueued %d jobs; want 0", n)
|
|
}
|
|
createTestItem(t, s, ws.ID, col.ID, "In scope", "")
|
|
if n := countDecisionJobs(t, s); n != 1 {
|
|
t.Fatalf("an item in the scoped collection enqueued %d jobs; want 1", n)
|
|
}
|
|
}
|
|
|
|
// A claim is exclusive until its lease lapses, and then any runner may take it.
|
|
func TestDecisionJobs_ClaimIsExclusiveUntilLeaseLapses(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(string) []string { return []string{"triage"} })
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Claim", "")
|
|
|
|
a, err := s.ClaimDecisionJobs("runner-a", 10, time.Hour)
|
|
if err != nil || len(a) != 1 {
|
|
t.Fatalf("runner-a claimed %d (err %v); want 1", len(a), err)
|
|
}
|
|
b, err := s.ClaimDecisionJobs("runner-b", 10, time.Hour)
|
|
if err != nil || len(b) != 0 {
|
|
t.Fatalf("runner-b claimed %d under a live lease (err %v); want 0", len(b), err)
|
|
}
|
|
|
|
// A dead runner: its lease is already in the past.
|
|
if _, err := s.db.Exec(s.q(`UPDATE decision_jobs SET lease_expires_at = ? WHERE item_id = ?`),
|
|
decisionTime(time.Now().Add(-time.Minute)), item.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err = s.ClaimDecisionJobs("runner-b", 10, time.Hour)
|
|
if err != nil || len(b) != 1 {
|
|
t.Fatalf("runner-b claimed %d after the lease lapsed (err %v); want 1", len(b), err)
|
|
}
|
|
// The dead runner's late completion must not delete runner-b's job.
|
|
if err := s.CompleteDecisionJob(a[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if j := mustJob(t, s, item.ID, "triage"); j.ClaimedBy != "runner-b" {
|
|
t.Fatalf("after the stale completion the job is claimed by %q; want runner-b", j.ClaimedBy)
|
|
}
|
|
}
|
|
|
|
// A write that lands mid-evaluation leaves the job owed: completion of the
|
|
// older generation releases rather than deletes.
|
|
func TestDecisionJobs_ReenqueueDuringEvaluationSurvivesCompletion(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(string) []string { return []string{"triage"} })
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Race", "")
|
|
|
|
claimed, err := s.ClaimDecisionJobs("r", 10, time.Hour)
|
|
if err != nil || len(claimed) != 1 {
|
|
t.Fatalf("claim: %d, %v", len(claimed), err)
|
|
}
|
|
title := "Race, edited mid-evaluation"
|
|
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{Title: &title}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CompleteDecisionJob(claimed[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := mustJob(t, s, item.ID, "triage")
|
|
if j.Generation != 2 || j.ClaimedBy != "" {
|
|
t.Fatalf("after completing gen 1 under a gen-2 enqueue: generation=%d claimed_by=%q; want 2, unclaimed",
|
|
j.Generation, j.ClaimedBy)
|
|
}
|
|
again, err := s.ClaimDecisionJobs("r", 10, time.Hour)
|
|
if err != nil || len(again) != 1 || again[0].Generation != 2 {
|
|
t.Fatalf("the newer generation is not claimable at once: %+v, %v", again, err)
|
|
}
|
|
|
|
// Control: with no intervening write, completion deletes.
|
|
if err := s.CompleteDecisionJob(again[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := countDecisionJobs(t, s); n != 0 {
|
|
t.Fatalf("completing the current generation left %d rows; want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestDecisionJobs_FailureBacksOff(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(string) []string { return []string{"triage"} })
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Fail", "")
|
|
|
|
claimed, _ := s.ClaimDecisionJobs("r", 10, time.Hour)
|
|
if err := s.FailDecisionJob(claimed[0], errors.New("provider 503"), time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := mustJob(t, s, item.ID, "triage")
|
|
if j.Attempts != 1 || j.LastError != "provider 503" {
|
|
t.Fatalf("after one failure: attempts=%d last_error=%q", j.Attempts, j.LastError)
|
|
}
|
|
if again, _ := s.ClaimDecisionJobs("r2", 10, time.Hour); len(again) != 0 {
|
|
t.Fatalf("a job inside its backoff was claimed")
|
|
}
|
|
|
|
// A new write is new work: it resets the failure history.
|
|
title := "Fail, edited"
|
|
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{Title: &title}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if j := mustJob(t, s, item.ID, "triage"); j.Attempts != 0 || j.LastError != "" {
|
|
t.Fatalf("an enqueue did not reset failure history: attempts=%d last_error=%q", j.Attempts, j.LastError)
|
|
}
|
|
}
|
|
|
|
// The read returns one row per question — the newest — and keeps the rest.
|
|
func TestItemDecisions_LatestHidesOlderStates(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Read", "")
|
|
|
|
row := func(key, hash, at string) models.ItemDecision {
|
|
return models.ItemDecision{
|
|
ItemID: item.ID, QuestionSet: "triage", QuestionKey: key, Kind: "noul",
|
|
Answer: []byte(`{"type":"noul","noul":0.5}`), Provider: "fake", Model: "m",
|
|
StateHash: hash, ItemSeq: 1, EvaluatedAt: at,
|
|
}
|
|
}
|
|
t0 := time.Now().UTC()
|
|
// Two evaluations inside the SAME second: the fixed-width nanosecond
|
|
// format is what orders them.
|
|
older := decisionTime(t0)
|
|
newer := decisionTime(t0.Add(time.Millisecond))
|
|
if err := s.InsertItemDecisions(ws.ID, []models.ItemDecision{row("urgent", "h1", older), row("stale", "h1", older)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.InsertItemDecisions(ws.ID, []models.ItemDecision{row("urgent", "h2", newer)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A duplicate of an existing state is kept, not replaced.
|
|
if err := s.InsertItemDecisions(ws.ID, []models.ItemDecision{row("urgent", "h2", decisionTime(t0.Add(time.Second)))}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.LatestItemDecisions(item.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("latest read returned %d rows; want 2 (one per key): %+v", len(got), got)
|
|
}
|
|
byKey := map[string]models.ItemDecision{}
|
|
for _, d := range got {
|
|
byKey[d.QuestionKey] = d
|
|
}
|
|
if byKey["urgent"].StateHash != "h2" || byKey["urgent"].EvaluatedAt != newer {
|
|
t.Fatalf("urgent: got hash %q at %q; want h2 at %q", byKey["urgent"].StateHash, byKey["urgent"].EvaluatedAt, newer)
|
|
}
|
|
if byKey["stale"].StateHash != "h1" {
|
|
t.Fatalf("stale: got hash %q; want h1", byKey["stale"].StateHash)
|
|
}
|
|
var total int
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM item_decisions`).Scan(&total); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 3 {
|
|
t.Fatalf("item_decisions holds %d rows; want 3 (older state kept for the audit, duplicate refused)", total)
|
|
}
|
|
if ok, _ := s.HasItemDecisionsAtState(item.ID, "triage", "h2", map[string]string{"urgent": "", "stale": ""}); ok {
|
|
t.Fatal("HasItemDecisionsAtState reported a complete set at h2, which has only one of two keys")
|
|
}
|
|
if ok, _ := s.HasItemDecisionsAtState(item.ID, "triage", "h1", map[string]string{"urgent": "", "stale": ""}); !ok {
|
|
t.Fatal("HasItemDecisionsAtState missed the complete set at h1")
|
|
}
|
|
}
|
|
|
|
// Move is a door (TASK-3117 ruling 3), keyed on the TARGET collection's sets:
|
|
// a move INTO a scoped collection makes an evaluation owed where the item's
|
|
// old collection owed none.
|
|
func TestDecisionJobs_MoveEnqueuesForTargetCollection(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
other := createTestCollection(t, s, ws.ID, "Ideas")
|
|
s.SetDecisionSetResolver(func(slug string) []string {
|
|
if slug == col.Slug {
|
|
return []string{"triage"}
|
|
}
|
|
return nil
|
|
})
|
|
item := createTestItem(t, s, ws.ID, other.ID, "Mover", "")
|
|
if n := countDecisionJobs(t, s); n != 0 {
|
|
t.Fatalf("precondition: item in an unscoped collection enqueued %d jobs", n)
|
|
}
|
|
if _, err := s.MoveItem(item.ID, col.ID, item.Fields); err != nil {
|
|
t.Fatalf("move: %v", err)
|
|
}
|
|
if j := mustJob(t, s, item.ID, "triage"); j.Generation != 1 {
|
|
t.Fatalf("after move into the scoped collection, generation = %d; want 1", j.Generation)
|
|
}
|
|
}
|
|
|
|
// Comment edit and delete change the trail, so they are doors too (ruling 3's
|
|
// rule applied). An edit that leaves the body unchanged is not.
|
|
func TestDecisionJobs_CommentEditAndDeleteEnqueue(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Trail", "")
|
|
c, err := s.CreateComment(ws.ID, item.ID, "", models.CommentCreate{Author: "a", Body: "first"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.SetDecisionSetResolver(func(string) []string { return []string{"triage"} })
|
|
|
|
if _, err := s.UpdateComment(c.ID, "first"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if j, _ := s.GetDecisionJob(item.ID, "triage"); j != nil {
|
|
t.Fatalf("a no-op comment edit enqueued: %+v", j)
|
|
}
|
|
if _, err := s.UpdateComment(c.ID, "first, corrected"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g := mustJob(t, s, item.ID, "triage").Generation; g != 1 {
|
|
t.Fatalf("after a comment edit, generation = %d; want 1", g)
|
|
}
|
|
if err := s.DeleteComment(c.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g := mustJob(t, s, item.ID, "triage").Generation; g != 2 {
|
|
t.Fatalf("after a comment delete, generation = %d; want 2", g)
|
|
}
|
|
}
|
|
|
|
// Codex round 2 P1: dropping a claim (retry limit, gone item) must not take a
|
|
// NEWER generation with it — a write that landed mid-evaluation is still owed.
|
|
func TestDecisionJobs_DropPreservesANewerGeneration(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(string) []string { return []string{"triage"} })
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Drop", "")
|
|
claimed, err := s.ClaimDecisionJobs("r", 10, time.Hour)
|
|
if err != nil || len(claimed) != 1 {
|
|
t.Fatalf("claim: %d %v", len(claimed), err)
|
|
}
|
|
title := "Drop, edited mid-evaluation"
|
|
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{Title: &title}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.DropDecisionJob(claimed[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := mustJob(t, s, item.ID, "triage")
|
|
if j.Generation != 2 || j.ClaimedBy != "" {
|
|
t.Fatalf("after dropping gen 1 under a gen-2 enqueue: %+v; want generation 2, unclaimed", j)
|
|
}
|
|
|
|
// Control: with no intervening write the drop removes the row.
|
|
again, _ := s.ClaimDecisionJobs("r", 10, time.Hour)
|
|
if err := s.DropDecisionJob(again[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := countDecisionJobs(t, s); n != 0 {
|
|
t.Fatalf("dropping the current generation left %d rows", n)
|
|
}
|
|
}
|
|
|
|
// Codex round 3: a set whose keys CHANGED at an unchanged state is not
|
|
// complete. A rename keeps the count equal (old key stored, new key asked),
|
|
// so a count over every stored key would report it complete and the new key
|
|
// would never be answered.
|
|
func TestItemDecisions_HasStateCountsOnlyTheAskedKeys(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Keys", "")
|
|
row := func(key string) models.ItemDecision {
|
|
return models.ItemDecision{ItemID: item.ID, QuestionSet: "triage", QuestionKey: key, Kind: "noul",
|
|
Answer: []byte(`{"type":"noul","noul":0.5}`), Provider: "fake", Model: "m", StateHash: "h", ItemSeq: 1}
|
|
}
|
|
if err := s.InsertItemDecisions(ws.ID, []models.ItemDecision{row("urgent"), row("old_name")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ok, err := s.HasItemDecisionsAtState(item.ID, "triage", "h", map[string]string{"urgent": "", "new_name": ""}); err != nil || ok {
|
|
t.Fatalf("renamed key: HasItemDecisionsAtState = %v, %v; want false (new_name has no answer)", ok, err)
|
|
}
|
|
if ok, _ := s.HasItemDecisionsAtState(item.ID, "triage", "h", map[string]string{"urgent": ""}); !ok {
|
|
t.Fatal("control: a set whose every asked key is stored must read complete")
|
|
}
|
|
// Codex round 6: the same key under a different question hash (a reworded
|
|
// question, or a new model) is not an answer.
|
|
if ok, _ := s.HasItemDecisionsAtState(item.ID, "triage", "h", map[string]string{"urgent": "reworded"}); ok {
|
|
t.Fatal("an answer to an older question definition counted as an answer to the new one")
|
|
}
|
|
}
|
|
|
|
// Codex round 4: restore is a door. A job owed at delete time is dropped as
|
|
// gone; without an enqueue on restore the item would come back with stale
|
|
// answers and nothing scheduled.
|
|
func TestDecisionJobs_RestoreEnqueues(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(string) []string { return []string{"triage"} })
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Phoenix", "")
|
|
claimed, _ := s.ClaimDecisionJobs("r", 10, time.Hour)
|
|
if err := s.DropDecisionJob(claimed[0]); err != nil { // as the runner does for a gone item
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.DeleteItem(item.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := countDecisionJobs(t, s); n != 0 {
|
|
t.Fatalf("precondition: %d jobs before restore", n)
|
|
}
|
|
if _, err := s.RestoreItem(item.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if j := mustJob(t, s, item.ID, "triage"); j.Generation != 1 {
|
|
t.Fatalf("after restore, generation = %d; want 1", j.Generation)
|
|
}
|
|
}
|
|
|
|
// failOneAttentionJob owes, claims and fails one attention job with this
|
|
// message, and returns the job's stored state (BUG-4018).
|
|
func failOneAttentionJob(t *testing.T, s *Store, wsID, itemID, msg string) *DecisionJobState {
|
|
t.Helper()
|
|
if err := s.OweDecisionJobs(wsID, "attention", []string{itemID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claimed, err := s.ClaimDecisionJobs("r", 10, time.Hour)
|
|
if err != nil || len(claimed) != 1 {
|
|
t.Fatalf("claim: %d, %v", len(claimed), err)
|
|
}
|
|
if err := s.FailDecisionJob(claimed[0], errors.New(msg), time.Hour); err != nil {
|
|
t.Fatalf("FailDecisionJob must record the failure, got: %v", err)
|
|
}
|
|
return mustJob(t, s, itemID, "attention")
|
|
}
|
|
|
|
// A failure message is arbitrary provider bytes. Whatever it holds, the
|
|
// failure is recorded: attempts counted, the set reads as failing, and the
|
|
// job is no longer owed (BUG-4018; Postgres TEXT refuses invalid UTF-8).
|
|
func TestFailDecisionJob_RecordsAnyMessage(t *testing.T) {
|
|
cases := []struct {
|
|
name, msg string
|
|
want func(string) bool
|
|
}{
|
|
{"straddle 2-byte", strings.Repeat("a", 1023) + "é" + "tail", func(g string) bool { return g == strings.Repeat("a", 1023) }},
|
|
{"straddle 3-byte", strings.Repeat("a", 1022) + "€" + "tail", func(g string) bool { return g == strings.Repeat("a", 1022) }},
|
|
{"invalid utf8", "bad \xff\xfe bytes", func(g string) bool { return g == "bad \uFFFD bytes" }},
|
|
{"nul", "has \x00 nul", func(g string) bool { return g == "has \uFFFD nul" }},
|
|
{"short ascii", "provider 503", func(g string) bool { return g == "provider 503" }},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(string) []string { return nil })
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Fail", "")
|
|
j := failOneAttentionJob(t, s, ws.ID, item.ID, c.msg)
|
|
if !utf8.ValidString(j.LastError) || len(j.LastError) > maxDecisionErrorBytes || strings.ContainsRune(j.LastError, 0) {
|
|
t.Fatalf("last_error not clean: len=%d valid=%v", len(j.LastError), utf8.ValidString(j.LastError))
|
|
}
|
|
if !c.want(j.LastError) {
|
|
t.Fatalf("last_error = %q", j.LastError)
|
|
}
|
|
if j.Attempts != 1 {
|
|
t.Fatalf("attempts = %d, want 1", j.Attempts)
|
|
}
|
|
if failing, err := s.DecisionJobsFailing(ws.ID, "attention"); err != nil || !failing {
|
|
t.Fatalf("DecisionJobsFailing = %v, %v; want true", failing, err)
|
|
}
|
|
if got, err := s.OwedDecisionJobs(ws.ID, []string{item.ID}, "attention"); err != nil || got[item.ID] {
|
|
t.Fatalf("a failed job still reads as owed: %v %v", got, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// lastRuneStartAtOrBelow returns the largest rune start <= limit in a string
|
|
// built as pad ASCII bytes followed by n runes of width w. Computed from the
|
|
// construction, not from the function under test.
|
|
func lastRuneStartAtOrBelow(pad, w, n, limit int) int {
|
|
best := 0
|
|
for k := 0; k <= n; k++ {
|
|
if p := pad + k*w; p <= limit {
|
|
best = p
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func TestTruncateDecisionError_Boundary(t *testing.T) {
|
|
for _, r := range []string{"é", "€", "😀"} {
|
|
w := len(r)
|
|
for pad := 1010; pad <= 1024; pad++ {
|
|
in := strings.Repeat("a", pad) + r + r
|
|
want := in
|
|
if len(in) > maxDecisionErrorBytes {
|
|
want = in[:lastRuneStartAtOrBelow(pad, w, 2, maxDecisionErrorBytes)]
|
|
}
|
|
if got := truncateDecisionError(in); got != want {
|
|
t.Fatalf("pad=%d rune=%q: len(got)=%d len(want)=%d", pad, r, len(got), len(want))
|
|
}
|
|
}
|
|
}
|
|
exact := strings.Repeat("a", maxDecisionErrorBytes)
|
|
if got := truncateDecisionError(exact); got != exact {
|
|
t.Fatalf("exact-limit ASCII changed: len=%d", len(got))
|
|
}
|
|
// Plain ASCII just over the limit pins the limit check directly: a check
|
|
// off by one byte (len(s) <= max+1) returns all 1025 bytes here. The
|
|
// rune cases above catch it too, at pad 1021 with a 2-byte rune (1025
|
|
// bytes), but only as a side effect of their own cut (BUG-4018 r2).
|
|
for _, n := range []int{maxDecisionErrorBytes + 1, 2000} {
|
|
over := strings.Repeat("a", n)
|
|
if got := truncateDecisionError(over); got != over[:maxDecisionErrorBytes] {
|
|
t.Fatalf("%d ASCII bytes: got len=%d, want exactly %d", n, len(got), maxDecisionErrorBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitising can grow a message (NUL or an invalid byte becomes a 3-byte
|
|
// U+FFFD), so it must run BEFORE the cut or the stored value can exceed the
|
|
// limit (BUG-4018).
|
|
func TestFailDecisionJob_SanitiseBeforeCut(t *testing.T) {
|
|
interleaved := strings.Repeat("a\xff", 400)
|
|
cases := []struct {
|
|
name, msg, want string
|
|
pre func(t *testing.T)
|
|
}{
|
|
{
|
|
name: "under limit raw, over after sanitise",
|
|
msg: interleaved,
|
|
want: strings.Repeat("a\uFFFD", 256), // 256*4 = 1024
|
|
pre: func(t *testing.T) {
|
|
if len(interleaved) >= maxDecisionErrorBytes || len(strings.ToValidUTF8(interleaved, "\uFFFD")) <= maxDecisionErrorBytes {
|
|
t.Fatal("precondition: raw < 1024 and sanitised > 1024")
|
|
}
|
|
},
|
|
},
|
|
{"nul straddling the boundary", strings.Repeat("a", 1023) + "\x00tail", strings.Repeat("a", 1023), nil},
|
|
{"nul just below the boundary", strings.Repeat("a", 1022) + "\x00tail", strings.Repeat("a", 1022), nil},
|
|
{"4-byte rune straddling", strings.Repeat("a", 1022) + "😀tail", strings.Repeat("a", 1022), nil},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if c.pre != nil {
|
|
c.pre(t)
|
|
}
|
|
s, ws, col := decisionFixture(t)
|
|
s.SetDecisionSetResolver(func(string) []string { return nil })
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Fail", "")
|
|
j := failOneAttentionJob(t, s, ws.ID, item.ID, c.msg)
|
|
if !utf8.ValidString(j.LastError) || strings.ContainsRune(j.LastError, 0) || len(j.LastError) > maxDecisionErrorBytes {
|
|
t.Fatalf("last_error not clean: len=%d valid=%v", len(j.LastError), utf8.ValidString(j.LastError))
|
|
}
|
|
if j.LastError != c.want {
|
|
t.Fatalf("last_error len=%d, want len=%d", len(j.LastError), len(c.want))
|
|
}
|
|
if j.Attempts != 1 {
|
|
t.Fatalf("attempts = %d, want 1", j.Attempts)
|
|
}
|
|
if failing, err := s.DecisionJobsFailing(ws.ID, "attention"); err != nil || !failing {
|
|
t.Fatalf("DecisionJobsFailing = %v, %v; want true", failing, err)
|
|
}
|
|
if got, err := s.OwedDecisionJobs(ws.ID, []string{item.ID}, "attention"); err != nil || got[item.ID] {
|
|
t.Fatalf("a failed job still reads as owed: %v %v", got, err)
|
|
}
|
|
})
|
|
}
|
|
}
|