diff --git a/internal/connector/admission/commit.go b/internal/connector/admission/commit.go index f8d356055..13a3cbce0 100644 --- a/internal/connector/admission/commit.go +++ b/internal/connector/admission/commit.go @@ -13,7 +13,7 @@ import ( var ErrAlreadyDecided = errors.New("admission: event already decided") // Ledger is the durable half of a verdict. Intake's SQLite ledger implements -// it once basecamp-cli PR 729 merges; until then only tests do. +// it (internal/connector, Ledger.Admission). type Ledger interface { // Commit writes v onto its event's record in one transaction and returns // the state it wrote. Within that transaction it must: diff --git a/internal/connector/admission/doc.go b/internal/connector/admission/doc.go index 1167b0fb0..0e6626566 100644 --- a/internal/connector/admission/doc.go +++ b/internal/connector/admission/doc.go @@ -73,13 +73,14 @@ // // # The seam with intake // -// Intake (basecamp-cli PR 729, internal/connector) hands admission an event id -// through its Queue; the record behind that id is the ledger row, whose -// pointer fields are exactly Event's. Run is written against two small -// interfaces, IDSource (satisfied by that Queue's Take) and Records (a ledger -// read returning a seen record as an Event), plus Ledger, whose Commit -// contract is invariant 5. The adapter from intake's ledger to those -// interfaces lands once 729 merges; this package imports nothing from it. +// Intake (internal/connector) hands admission an event id through its Queue; +// the record behind that id is the ledger row, whose pointer fields are +// exactly Event's. Run is written against two small interfaces, IDSource +// (satisfied by that Queue's Take) and Records (a ledger read returning a seen +// record as an Event), plus Ledger, whose Commit contract is invariant 5. +// Intake's ledger implements Records and Ledger (connector.Ledger.Admission), +// and connector.RunAdmission wires Run onto the queue; this package imports +// nothing from that one. // // # What is not here // diff --git a/internal/connector/admission/event.go b/internal/connector/admission/event.go index 1a19431ff..bcd23a0f6 100644 --- a/internal/connector/admission/event.go +++ b/internal/connector/admission/event.go @@ -10,7 +10,7 @@ import ( const ActorTypeAgent = "agent" // Event is the pointer admission decides on: the fields intake stores for a -// seen record (internal/connector Record in basecamp-cli PR 729), and nothing +// seen record (internal/connector's Record), and nothing // else. It carries no title, no content, no URL and no names; whatever // admission needs beyond ids it reads. type Event struct { diff --git a/internal/connector/admission/run.go b/internal/connector/admission/run.go index 421d94f02..37fcf079b 100644 --- a/internal/connector/admission/run.go +++ b/internal/connector/admission/run.go @@ -15,8 +15,8 @@ import ( // DefaultWorkers is the admission fetcher pool size. const DefaultWorkers = 4 -// IDSource hands over the next event id to admit. Intake's Queue (basecamp-cli -// PR 729, internal/connector) satisfies it. +// IDSource hands over the next event id to admit. Intake's Queue +// (internal/connector) satisfies it. type IDSource interface { Take(ctx context.Context) (int64, error) } @@ -27,8 +27,8 @@ type IDSource interface { // false when the id is unknown or the record is past deciding, so it is // skipped rather than decided twice. // -// This is the seam where intake hands admission an event: the adapter over -// intake's Ledger.Get lands once PR 729 merges. +// This is the seam where intake hands admission an event; intake's ledger +// implements it (internal/connector, Ledger.Admission). type Records interface { LoadUndecided(ctx context.Context, id int64) (ev Event, ok bool, err error) } diff --git a/internal/connector/intake.go b/internal/connector/intake.go index e47d2e5b3..21708f154 100644 --- a/internal/connector/intake.go +++ b/internal/connector/intake.go @@ -855,9 +855,9 @@ func (in *Intake) sweepStranded(ctx context.Context) { // so that a crash can never lose the pointer — but that ordering means a // failure after the commit leaves an event the ledger's own dedupe will // suppress on every retry. Nothing may be left in that state, so a start -// offers every record nothing has judged yet. Offering one twice costs -// nothing: the queue carries ids, and admission moves a record out of seen -// before it acts. +// offers every record nothing has judged yet. Offering one twice costs at +// most a second decision, never a second verdict: the queue carries ids, and +// admission's commit applies only at the revision its decision loaded. // // The pointer line is NOT written again here. A record that got no line when // it was recorded — the write that failed — never gets one, and the work still diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go index 0a9589bd3..90a4fdfe6 100644 --- a/internal/connector/ledger.go +++ b/internal/connector/ledger.go @@ -281,6 +281,47 @@ WHEN OLD.state IN ('completed', 'discarded') AND NEW.state <> OLD.state BEGIN SELECT RAISE(ABORT, 'a terminal record cannot change state'); END; +`, + // Migration 4. What admission decides is written onto the record it + // decided, in the transaction that decides it. + // + // revision is the guard on that write: a decision carries the revision + // the record was loaded at, and applies only while the record is still + // there, so one event gets one verdict however many fetches decide it and + // an older decision never overwrites a newer one. Every state change bumps + // it, not only admission's. + // + // decided_at is when the latest verdict was written, blocked_at when the + // record entered its current run of blocked verdicts (the retry window + // counts from it), and retry_at a throttled verdict's server deadline. + // The rest is the verdict itself — what dispatch starts a task from, and + // what a blocked(no_route) record's holding reply needs. snapshot is the + // recording's content as admission read it. Only an admitted verdict + // writes one, whether the ledger writes it as admitted or as queued; a + // record keeps it through dispatch and completion until retention drops + // it, and loses it on any move to blocked or discarded. Nothing else in + // the ledger is content. + // + // Nothing has shipped that wrote a version 3 ledger, but a migration is + // how the schema changes regardless: the next time something has, this is + // the path that has to work. + ` +ALTER TABLE events ADD COLUMN revision INTEGER NOT NULL DEFAULT 0; +ALTER TABLE events ADD COLUMN decided_at TEXT; +ALTER TABLE events ADD COLUMN blocked_at TEXT; +ALTER TABLE events ADD COLUMN retry_at TEXT; +ALTER TABLE events ADD COLUMN trigger_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE events ADD COLUMN acknowledge INTEGER NOT NULL DEFAULT 0; +ALTER TABLE events ADD COLUMN conversation_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE events ADD COLUMN reply_kind TEXT NOT NULL DEFAULT ''; +ALTER TABLE events ADD COLUMN reply_recording_id INTEGER NOT NULL DEFAULT 0; +ALTER TABLE events ADD COLUMN routed INTEGER NOT NULL DEFAULT 0; +ALTER TABLE events ADD COLUMN route TEXT NOT NULL DEFAULT ''; +ALTER TABLE events ADD COLUMN class TEXT NOT NULL DEFAULT ''; +ALTER TABLE events ADD COLUMN recording_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE events ADD COLUMN requester_id INTEGER NOT NULL DEFAULT 0; +ALTER TABLE events ADD COLUMN snapshot BLOB; +CREATE INDEX events_conversation ON events (conversation_key, state); `, } diff --git a/internal/connector/ledger_admission.go b/internal/connector/ledger_admission.go new file mode 100644 index 000000000..d46aad1d2 --- /dev/null +++ b/internal/connector/ledger_admission.go @@ -0,0 +1,250 @@ +package connector + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/basecamp/basecamp-cli/internal/connector/admission" + "github.com/basecamp/basecamp-cli/internal/connector/ndjson" +) + +// Admission is the ledger as admission sees it: the Records it loads events +// from and the Ledger its verdicts are committed to. +// +// Admission is its own package and imports nothing from this one. This is the +// whole of the join: the pointer intake recorded goes out as an admission +// Event with the revision it was loaded at, and a verdict comes back through +// the ledger's one state-changing write, so the lifecycle that keeps a +// finished record finished is the same lifecycle a verdict meets. +type Admission struct { + ledger *Ledger +} + +var ( + _ admission.Records = Admission{} + _ admission.Ledger = Admission{} +) + +// Admission returns the ledger's admission seam. +func (l *Ledger) Admission() Admission { return Admission{ledger: l} } + +// undecided is where a record may be for admission to decide it: seen, which +// nothing has judged yet, and blocked, which recovery and redispatch decide +// again. Everything past these was decided by admission or moved on by +// dispatch, and is never decided a second time. +var undecided = []RecordState{StateSeen, StateBlocked} + +// LoadUndecided loads a seen or blocked record as the event admission decides, +// with the revision it was loaded at. An unknown id, or a record past +// deciding, is not ok and is skipped. +func (a Admission) LoadUndecided(ctx context.Context, id int64) (admission.Event, bool, error) { + record, ok, err := a.ledger.Get(ctx, id) + if err != nil || !ok { + return admission.Event{}, false, err + } + if record.State != StateSeen && record.State != StateBlocked { + return admission.Event{}, false, nil + } + return admission.Event{ + ID: record.ID, + EventType: record.EventType, + BucketID: record.BucketID, + RecordingID: record.RecordingID, + CreatorID: record.CreatorID, + PerformedByID: record.PerformedByID, + ActorType: record.ActorType, + Details: record.Details, + Revision: record.Revision, + SeenAt: record.SeenAt, + }, true, nil +} + +// Commit writes a verdict onto its record in one transaction, and returns the +// state it wrote. It holds admission.Ledger's contract: +// +// - the write applies only while the record is still at the verdict's +// revision and still undecided, and it bumps the revision; otherwise it +// is admission.ErrAlreadyDecided; +// - an admitted verdict is written as queued when its conversation is +// live, decided inside the transaction: another record on the key is +// admitted and not yet dispatched (it becomes the task) or dispatched +// (the task is running). A queued record alone is not a live +// conversation: queued records join their task before it closes, and +// counting one left behind would queue every later event on the key +// with nothing to start a task; +// - content is written only with an admitted verdict; +// - a throttled verdict keeps its RetryAt. +func (a Admission) Commit(ctx context.Context, v admission.Verdict) (admission.State, error) { + state, err := verdictState(v) + if err != nil { + return "", err + } + var snapshot []byte + if v.Snapshot != nil { + if snapshot, err = json.Marshal(v.Snapshot); err != nil { + return "", fmt.Errorf("connector: commit verdict on %d: %w", v.EventID, err) + } + } + + var written RecordState + err = retryBusy(func() error { + var err error + written, err = a.commit(ctx, v, state, snapshot) + return err + }) + if err != nil { + return "", err + } + return admission.State(written), nil +} + +func (a Admission) commit(ctx context.Context, v admission.Verdict, state RecordState, snapshot []byte) (RecordState, error) { + l := a.ledger + tx, err := l.db.BeginTx(ctx, nil) + if err != nil { + return "", fmt.Errorf("connector: begin verdict on %d: %w", v.EventID, err) + } + defer func() { _ = tx.Rollback() }() + + if state == StateAdmitted { + // The record being decided is seen or blocked, so it never counts + // itself as the conversation's task. + var live bool + if err := tx.QueryRowContext(ctx, liveConversation, v.ConversationKey, string(StateAdmitted), string(StateDispatched)).Scan(&live); err != nil { + return "", fmt.Errorf("connector: read conversation of %d: %w", v.EventID, err) + } + if live { + state = StateQueued + } + } + + var retryAt time.Time + if state == StateBlocked { + retryAt = v.RetryAt + } + reply := admission.ReplyDestination{} + if v.Reply != nil { + reply = *v.Reply + } + revision := v.Revision + moved, err := l.move(ctx, tx, transition{ + id: v.EventID, + state: state, + reason: string(v.Reason), + from: undecided, + revision: &revision, + retryAt: retryAt, + set: []assignment{ + {column: "decided_at", value: l.timestamp()}, + {column: "trigger_name", value: string(v.Trigger)}, + {column: "acknowledge", value: v.Acknowledge}, + {column: "conversation_key", value: v.ConversationKey}, + {column: "reply_kind", value: string(reply.Kind)}, + {column: "reply_recording_id", value: reply.RecordingID}, + {column: "routed", value: v.Routed}, + {column: "route", value: v.Route}, + {column: "class", value: v.Class}, + {column: "recording_url", value: v.RecordingURL}, + {column: "requester_id", value: v.RequesterID}, + {column: "snapshot", value: snapshot}, + }, + }) + if err != nil { + return "", err + } + if !moved { + return "", explainVerdictRefusal(ctx, tx, v) + } + if err := tx.Commit(); err != nil { + return "", fmt.Errorf("connector: commit verdict on %d: %w", v.EventID, err) + } + return state, nil +} + +// liveConversation asks whether a conversation has a task running or a record +// about to become one. It is read through events_conversation. +const liveConversation = ` +SELECT EXISTS ( + SELECT 1 FROM events + WHERE conversation_key = ? AND state IN (?, ?) +)` + +// verdictState checks a verdict is one the ledger can write and maps it to +// the ledger's state. Queued is the ledger's decision, never a verdict's. +func verdictState(v admission.Verdict) (RecordState, error) { + refuse := func(why string) (RecordState, error) { + return "", fmt.Errorf("connector: verdict on %d refused: %s", v.EventID, why) + } + switch v.State { + case admission.StateAdmitted: + if v.Snapshot == nil { + return refuse("an admitted verdict carries the recording it admitted") + } + if v.ConversationKey == "" { + return refuse("an admitted verdict needs a conversation key, or it can never queue") + } + return StateAdmitted, nil + case admission.StateBlocked, admission.StateDiscarded: + if v.Snapshot != nil { + return refuse("only an admitted verdict carries content") + } + return RecordState(v.State), nil + default: + return refuse(fmt.Sprintf("%q is not a state admission decides", v.State)) + } +} + +// explainVerdictRefusal says why a verdict changed nothing, reading the row in +// the verdict's own transaction so the answer is the state that refused it. +func explainVerdictRefusal(ctx context.Context, tx dbtx, v admission.Verdict) error { + var ( + state string + revision int64 + ) + switch err := tx.QueryRowContext(ctx, `SELECT state, revision FROM events WHERE id = ?`, v.EventID).Scan(&state, &revision); { + case errors.Is(err, sql.ErrNoRows): + return fmt.Errorf("connector: verdict on %d: %w", v.EventID, ErrNoSuchRecord) + case err != nil: + return fmt.Errorf("connector: verdict on %d: %w", v.EventID, err) + } + // Moved on since the decision loaded it, by another decision or by + // dispatch: the newer state stands. + return fmt.Errorf("connector: verdict on %d loaded at revision %d, record is %s at revision %d: %w", + v.EventID, v.Revision, state, revision, admission.ErrAlreadyDecided) +} + +// AdmissionOptions wires admission onto intake's queue and ledger. +type AdmissionOptions struct { + Ledger *Ledger + Queue *Queue + Admitter *admission.Admitter + // Workers is the fetcher pool size; admission's default when zero. + Workers int + // Lines is the writer intake's pointer lines go through. Verdict lines + // share it, so the two never tear each other on one stdout. + Lines *ndjson.Writer + Logger *slog.Logger +} + +// RunAdmission takes the ids intake hands over and decides each against the +// ledger, until ctx ends. It returns what admission.Run returns. +func RunAdmission(ctx context.Context, opts AdmissionOptions) error { + if opts.Ledger == nil || opts.Queue == nil { + return errors.New("connector: admission needs the ledger and the queue intake writes to") + } + records := opts.Ledger.Admission() + return admission.Run(ctx, admission.RunOptions{ + Source: opts.Queue, + Records: records, + Admitter: opts.Admitter, + Committer: admission.NewCommitter(records), + Workers: opts.Workers, + LineWriter: opts.Lines, + Logger: opts.Logger, + }) +} diff --git a/internal/connector/ledger_admission_test.go b/internal/connector/ledger_admission_test.go new file mode 100644 index 000000000..fb6ca95b9 --- /dev/null +++ b/internal/connector/ledger_admission_test.go @@ -0,0 +1,742 @@ +package connector + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/connector/admission" + "github.com/basecamp/basecamp-cli/internal/connector/ndjson" +) + +// The join between intake's ledger and admission. Admission's own tests hold +// its contract against a fake; these hold the real ledger to the same one. + +const ( + adapterAgentID int64 = 52007412 + adapterOperatorID int64 = 26909558 + adapterBucketID int64 = 48699913 +) + +func seenRecord(t *testing.T, ledger *Ledger, id int64) Record { + t.Helper() + ctx := context.Background() + _, err := ledger.RecordSeen(ctx, testEvent(id), LanePoll) + require.NoError(t, err) + record, ok, err := ledger.Get(ctx, id) + require.NoError(t, err) + require.True(t, ok) + return record +} + +func getRecord(t *testing.T, ledger *Ledger, id int64) Record { + t.Helper() + record, ok, err := ledger.Get(context.Background(), id) + require.NoError(t, err) + require.True(t, ok) + return record +} + +func admittedVerdict(id, revision int64, key string) admission.Verdict { + return admission.Verdict{ + EventID: id, + EventType: "comment.created", + BucketID: adapterBucketID, + RecordingID: 10304028972, + Revision: revision, + RequesterID: adapterOperatorID, + State: admission.StateAdmitted, + Trigger: admission.TriggerMentioned, + Acknowledge: true, + ConversationKey: key, + Reply: &admission.ReplyDestination{Kind: admission.ReplyComment, RecordingID: 10304028989}, + Routed: true, + Route: "/work/connector", + Class: "internal", + RecordingURL: "https://app.basecamp.com/2914079/buckets/48699913/recordings/10304028972", + Snapshot: &admission.Snapshot{ + Type: "Comment", + Title: "A comment", + AppURL: "https://app.basecamp.com/2914079/buckets/48699913/recordings/10304028972", + Content: "
please look
", + UpdatedAt: time.Date(2026, 9, 16, 10, 0, 0, 0, time.UTC), + }, + } +} + +func blockedVerdict(id, revision int64, reason admission.Reason) admission.Verdict { + return admission.Verdict{ + EventID: id, EventType: "comment.created", BucketID: adapterBucketID, + RecordingID: 10304028972, Revision: revision, RequesterID: adapterOperatorID, + State: admission.StateBlocked, Reason: reason, + } +} + +func TestAdmissionLoadsAnUndecidedRecordAtItsRevision(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seen := seenRecord(t, ledger, 1) + store := ledger.Admission() + + ev, ok, err := store.LoadUndecided(ctx, 1) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, admission.Event{ + ID: 1, EventType: "comment.created", BucketID: adapterBucketID, RecordingID: 10304028972, + CreatorID: adapterOperatorID, Revision: 0, SeenAt: seen.SeenAt, + }, ev) + assert.False(t, ev.SeenAt.IsZero(), "admission dates membership refusals from SeenAt") + + // A blocked record is decided again, at the revision its verdict left. + _, err = store.Commit(ctx, blockedVerdict(1, 0, admission.ReasonReadFailed)) + require.NoError(t, err) + ev, ok, err = store.LoadUndecided(ctx, 1) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, int64(1), ev.Revision) + + _, ok, err = store.LoadUndecided(ctx, 404) + require.NoError(t, err) + assert.False(t, ok, "an unknown id is skipped") +} + +func TestAdmissionSkipsARecordPastDeciding(t *testing.T) { + for _, state := range []RecordState{StateAdmitted, StateQueued, StateDispatched, StateCompleted, StateDiscarded} { + t.Run(string(state), func(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seenRecord(t, ledger, 1) + switch state { + case StateDiscarded: + require.NoError(t, ledger.SetState(ctx, 1, StateDiscarded, "untrusted_author")) + case StateCompleted: + require.NoError(t, reachTerminal(t, ledger, 7, StateCompleted)) + require.NoError(t, ledger.SetState(ctx, 1, StateAdmitted, "")) + require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, "")) + require.NoError(t, ledger.SetState(ctx, 1, StateCompleted, "")) + case StateDispatched: + require.NoError(t, ledger.SetState(ctx, 1, StateAdmitted, "")) + require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, "")) + default: + require.NoError(t, ledger.SetState(ctx, 1, state, "")) + } + + _, ok, err := ledger.Admission().LoadUndecided(ctx, 1) + require.NoError(t, err) + assert.False(t, ok) + }) + } +} + +func TestAdmissionCommitWritesTheVerdictOntoTheRecord(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + at := time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC) + ledger.now = func() time.Time { return at } + seenRecord(t, ledger, 1) + v := admittedVerdict(1, 0, "recording:10304028989") + + written, err := ledger.Admission().Commit(ctx, v) + require.NoError(t, err) + assert.Equal(t, admission.StateAdmitted, written) + + record := getRecord(t, ledger, 1) + assert.Equal(t, StateAdmitted, record.State) + assert.Empty(t, record.Reason) + assert.Equal(t, int64(1), record.Revision) + d := record.Decision + require.NotNil(t, d.DecidedAt) + assert.True(t, at.Equal(*d.DecidedAt)) + assert.Nil(t, d.BlockedAt) + assert.Nil(t, d.RetryAt) + assert.Equal(t, "mentioned", d.Trigger) + assert.True(t, d.Acknowledge) + assert.Equal(t, "recording:10304028989", d.ConversationKey) + assert.Equal(t, "comment", d.ReplyKind) + assert.Equal(t, int64(10304028989), d.ReplyRecordingID) + assert.True(t, d.Routed) + assert.Equal(t, "/work/connector", d.Route) + assert.Equal(t, "internal", d.Class) + assert.Equal(t, v.RecordingURL, d.RecordingURL) + assert.Equal(t, adapterOperatorID, d.RequesterID) + + var snapshot admission.Snapshot + require.NoError(t, json.Unmarshal(d.Snapshot, &snapshot)) + assert.Equal(t, *v.Snapshot, snapshot) +} + +// Invariant 5 of admission: one verdict per event. The same decision arriving +// twice — two fetchers, a restart — is written once. +func TestAdmissionCommitIsOneVerdictPerEvent(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seenRecord(t, ledger, 1) + store := ledger.Admission() + + _, err := store.Commit(ctx, admittedVerdict(1, 0, "recording:9")) + require.NoError(t, err) + _, err = store.Commit(ctx, admittedVerdict(1, 0, "recording:9")) + + require.ErrorIs(t, err, admission.ErrAlreadyDecided) + record := getRecord(t, ledger, 1) + assert.Equal(t, StateAdmitted, record.State, "not re-queued behind itself") + assert.Equal(t, int64(1), record.Revision) +} + +func TestAdmissionAnOlderDecisionNeverOverwritesANewer(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seenRecord(t, ledger, 1) + store := ledger.Admission() + + // Two decisions loaded the record at revision 0; the blocked one lands + // first. + _, err := store.Commit(ctx, blockedVerdict(1, 0, admission.ReasonReadFailed)) + require.NoError(t, err) + + _, err = store.Commit(ctx, admittedVerdict(1, 0, "recording:9")) + require.ErrorIs(t, err, admission.ErrAlreadyDecided) + record := getRecord(t, ledger, 1) + assert.Equal(t, StateBlocked, record.State, "a blocked verdict stands against an older decision too") + assert.Equal(t, "read_failed", record.Reason) + assert.Empty(t, record.Decision.Snapshot) + + // A decision loaded after it applies. + written, err := store.Commit(ctx, admittedVerdict(1, 1, "recording:9")) + require.NoError(t, err) + assert.Equal(t, admission.StateAdmitted, written) + assert.Empty(t, getRecord(t, ledger, 1).Reason) +} + +// Every state change bumps the revision, not only admission's: a decision +// loaded before anything else moved the record is stale. +func TestAdmissionAnyMoveStalesALoadedDecision(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seenRecord(t, ledger, 1) + store := ledger.Admission() + ev, ok, err := store.LoadUndecided(ctx, 1) + require.NoError(t, err) + require.True(t, ok) + + require.NoError(t, ledger.SetState(ctx, 1, StateBlocked, "no_route")) + + _, err = store.Commit(ctx, admittedVerdict(1, ev.Revision, "recording:9")) + require.ErrorIs(t, err, admission.ErrAlreadyDecided) + assert.Equal(t, StateBlocked, getRecord(t, ledger, 1).State) +} + +// A record admission already decided, or dispatch has moved on, is never +// decided again — even by a verdict carrying its current revision. +func TestAdmissionNeverDecidesARecordPastDeciding(t *testing.T) { + cases := map[string]func(*testing.T, *Ledger){ + "admitted": func(t *testing.T, l *Ledger) { + require.NoError(t, l.SetState(context.Background(), 1, StateAdmitted, "")) + }, + "dispatched": func(t *testing.T, l *Ledger) { + require.NoError(t, l.SetState(context.Background(), 1, StateAdmitted, "")) + require.NoError(t, l.SetState(context.Background(), 1, StateDispatched, "")) + }, + "discarded": func(t *testing.T, l *Ledger) { + require.NoError(t, l.SetState(context.Background(), 1, StateDiscarded, "by_operator")) + }, + } + for name, reach := range cases { + t.Run(name, func(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seenRecord(t, ledger, 1) + reach(t, ledger) + before := getRecord(t, ledger, 1) + + for _, v := range []admission.Verdict{ + admittedVerdict(1, before.Revision, "recording:9"), + blockedVerdict(1, before.Revision, admission.ReasonReadFailed), + } { + _, err := ledger.Admission().Commit(ctx, v) + require.ErrorIs(t, err, admission.ErrAlreadyDecided) + } + after := getRecord(t, ledger, 1) + assert.Equal(t, before.State, after.State) + assert.Equal(t, before.Revision, after.Revision) + }) + } +} + +func TestAdmissionCommitOnAMissingRecord(t *testing.T) { + _, err := newTestLedger(t).Admission().Commit(context.Background(), blockedVerdict(404, 0, admission.ReasonReadFailed)) + assert.ErrorIs(t, err, ErrNoSuchRecord) + assert.NotErrorIs(t, err, admission.ErrAlreadyDecided) +} + +// Admitted or queued is the ledger's decision, taken against the conversation +// as it stands in the verdict's own transaction. +func TestAdmissionQueuesBehindALiveConversation(t *testing.T) { + const key = "recording:10304028989" + cases := []struct { + name string + other func(*testing.T, *Ledger) + want admission.State + }{ + {"nothing else on the key", nil, admission.StateAdmitted}, + {"an admitted record becomes the task", func(t *testing.T, l *Ledger) { + _, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, key)) + require.NoError(t, err) + }, admission.StateQueued}, + {"a dispatched record is a running task", func(t *testing.T, l *Ledger) { + _, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, key)) + require.NoError(t, err) + require.NoError(t, l.SetState(context.Background(), 2, StateDispatched, "")) + }, admission.StateQueued}, + {"a queued record alone is not a task", func(t *testing.T, l *Ledger) { + _, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, key)) + require.NoError(t, err) + _, err = l.Admission().Commit(context.Background(), admittedVerdict(3, 0, key)) + require.NoError(t, err) + require.NoError(t, l.SetState(context.Background(), 2, StateDispatched, "")) + require.NoError(t, l.SetState(context.Background(), 2, StateCompleted, "")) + require.Equal(t, StateQueued, getRecord(t, l, 3).State) + }, admission.StateAdmitted}, + {"a completed task is not live", func(t *testing.T, l *Ledger) { + _, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, key)) + require.NoError(t, err) + require.NoError(t, l.SetState(context.Background(), 2, StateDispatched, "")) + require.NoError(t, l.SetState(context.Background(), 2, StateCompleted, "")) + }, admission.StateAdmitted}, + {"a blocked record is not a task", func(t *testing.T, l *Ledger) { + v := admittedVerdict(2, 0, key) + v.State, v.Reason, v.Snapshot = admission.StateBlocked, admission.ReasonNoRoute, nil + _, err := l.Admission().Commit(context.Background(), v) + require.NoError(t, err) + }, admission.StateAdmitted}, + {"a live task on another conversation", func(t *testing.T, l *Ledger) { + _, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, "recording:1")) + require.NoError(t, err) + }, admission.StateAdmitted}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ledger := newTestLedger(t) + seenRecord(t, ledger, 1) + seenRecord(t, ledger, 2) + seenRecord(t, ledger, 3) + if tc.other != nil { + tc.other(t, ledger) + } + + written, err := ledger.Admission().Commit(context.Background(), admittedVerdict(1, 0, key)) + require.NoError(t, err) + assert.Equal(t, tc.want, written) + assert.Equal(t, RecordState(tc.want), getRecord(t, ledger, 1).State) + if tc.name == "a queued record alone is not a task" { + // Liveness decides only the incoming record. The queued one + // is not moved, expired or re-decided by it. + third := getRecord(t, ledger, 3) + assert.Equal(t, StateQueued, third.State) + assert.Equal(t, int64(1), third.Revision) + } + }) + } +} + +// Only an admitted verdict carries content, and a verdict the ledger refuses +// writes nothing at all. +func TestAdmissionRefusesAMalformedVerdictWithoutWriting(t *testing.T) { + blockedWithContent := blockedVerdict(1, 0, admission.ReasonNoRoute) + blockedWithContent.Snapshot = admittedVerdict(1, 0, "k").Snapshot + discardedWithContent := blockedWithContent + discardedWithContent.State, discardedWithContent.Reason = admission.StateDiscarded, admission.ReasonStale + admittedWithout := admittedVerdict(1, 0, "recording:9") + admittedWithout.Snapshot = nil + admittedNoKey := admittedVerdict(1, 0, "") + queued := admittedVerdict(1, 0, "recording:9") + queued.State, queued.Snapshot = admission.StateQueued, nil + blockedNoReason := blockedVerdict(1, 0, "") + + for name, v := range map[string]admission.Verdict{ + "blocked with content": blockedWithContent, + "discarded with content": discardedWithContent, + "admitted without content": admittedWithout, + "admitted without a key": admittedNoKey, + "queued is not a verdict": queued, + "blocked without a reason": blockedNoReason, + } { + t.Run(name, func(t *testing.T) { + ledger := newTestLedger(t) + seenRecord(t, ledger, 1) + + _, err := ledger.Admission().Commit(context.Background(), v) + + require.Error(t, err) + assert.NotErrorIs(t, err, admission.ErrAlreadyDecided) + record := getRecord(t, ledger, 1) + assert.Equal(t, StateSeen, record.State) + assert.Equal(t, int64(0), record.Revision) + assert.Nil(t, record.Decision.DecidedAt) + assert.Empty(t, record.Decision.Snapshot) + }) + } +} + +// A throttled verdict keeps the server's deadline, and the retry window counts +// from the first of a run of blocked verdicts. +func TestAdmissionKeepsTheBlockedScheduleInputs(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + store := ledger.Admission() + t0 := time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC) + ledger.now = func() time.Time { return t0 } + seenRecord(t, ledger, 1) + + throttled := blockedVerdict(1, 0, admission.ReasonThrottled) + throttled.RetryAt = t0.Add(7 * time.Minute) + _, err := store.Commit(ctx, throttled) + require.NoError(t, err) + d := getRecord(t, ledger, 1).Decision + require.NotNil(t, d.RetryAt) + assert.True(t, throttled.RetryAt.Equal(*d.RetryAt)) + require.NotNil(t, d.BlockedAt) + assert.True(t, t0.Equal(*d.BlockedAt)) + + // Re-run ten minutes later and blocked again, on a read this time. + t1 := t0.Add(10 * time.Minute) + ledger.now = func() time.Time { return t1 } + _, err = store.Commit(ctx, blockedVerdict(1, 1, admission.ReasonReadFailed)) + require.NoError(t, err) + d = getRecord(t, ledger, 1).Decision + assert.Nil(t, d.RetryAt, "only a throttled verdict has a deadline") + require.NotNil(t, d.BlockedAt) + assert.True(t, t0.Equal(*d.BlockedAt), "the window still counts from the first block") + require.NotNil(t, d.DecidedAt) + assert.True(t, t1.Equal(*d.DecidedAt), "the last attempt is the latest verdict") + + // NextBlockedRetry reads what the ledger kept. + next, ok := admission.NextBlockedRetry(admission.Reason(getRecord(t, ledger, 1).Reason), *d.BlockedAt, *d.DecidedAt, time.Time{}) + require.True(t, ok) + assert.True(t, t1.Add(admission.BlockedRetryInterval).Equal(next)) + + // Admitted at last: no longer blocked, and a deadline is a blocked + // record's alone. + admitted := admittedVerdict(1, 2, "recording:9") + admitted.RetryAt = t1.Add(time.Hour) + _, err = store.Commit(ctx, admitted) + require.NoError(t, err) + d = getRecord(t, ledger, 1).Decision + assert.Nil(t, d.BlockedAt) + assert.Nil(t, d.RetryAt) +} + +// The blocked schedule's inputs belong to every state write, not only to a +// verdict: a record moved out of blocked by anything forgets when it was +// blocked, one moved back in starts a new window, and a record parked on +// blocked or discarded carries no content. +func TestEveryMoveKeepsTheBlockedScheduleInputs(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + store := ledger.Admission() + t0 := time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC) + ledger.now = func() time.Time { return t0 } + seenRecord(t, ledger, 1) + throttled := blockedVerdict(1, 0, admission.ReasonThrottled) + throttled.RetryAt = t0.Add(time.Hour) + _, err := store.Commit(ctx, throttled) + require.NoError(t, err) + + // Out of blocked by a plain state write: no window, no deadline. + t1 := t0.Add(72 * time.Hour) + ledger.now = func() time.Time { return t1 } + require.NoError(t, ledger.SetState(ctx, 1, StateAdmitted, "")) + d := getRecord(t, ledger, 1).Decision + assert.Nil(t, d.BlockedAt, "a record that left blocked is not blocked") + assert.Nil(t, d.RetryAt) + + // Back into blocked after a dispatch: a new window from now, and the + // verdict that follows keeps it. + require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, "")) + require.NoError(t, ledger.SetState(ctx, 1, StateBlocked, "read_failed")) + record := getRecord(t, ledger, 1) + require.NotNil(t, record.Decision.BlockedAt) + assert.True(t, t1.Equal(*record.Decision.BlockedAt)) + t2 := t1.Add(10 * time.Minute) + ledger.now = func() time.Time { return t2 } + _, err = store.Commit(ctx, blockedVerdict(1, record.Revision, admission.ReasonReadFailed)) + require.NoError(t, err) + d = getRecord(t, ledger, 1).Decision + require.NotNil(t, d.BlockedAt) + _, ok := admission.NextBlockedRetry(admission.ReasonReadFailed, *d.BlockedAt, *d.DecidedAt, time.Time{}) + assert.True(t, ok, "a record blocked again after a redispatch still gets its timed retries") + + // A deadline is a blocked record's alone. + _, err = ledger.move(ctx, ledger.db, transition{id: 1, state: StateBlocked, reason: "read_failed", retryAt: t2}) + require.NoError(t, err) + _, err = ledger.move(ctx, ledger.db, transition{id: 1, state: StateAdmitted, retryAt: t2}) + require.Error(t, err) +} + +func TestAMoveToBlockedOrDiscardedDropsTheSnapshot(t *testing.T) { + for _, target := range []RecordState{StateBlocked, StateDiscarded} { + t.Run(string(target), func(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seenRecord(t, ledger, 1) + _, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9")) + require.NoError(t, err) + require.NotEmpty(t, getRecord(t, ledger, 1).Decision.Snapshot) + + require.NoError(t, ledger.SetState(ctx, 1, target, "by_operator")) + + assert.Empty(t, getRecord(t, ledger, 1).Decision.Snapshot) + }) + } + + t.Run("dispatch keeps it", func(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + seenRecord(t, ledger, 1) + _, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9")) + require.NoError(t, err) + require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, "")) + require.NoError(t, ledger.SetState(ctx, 1, StateCompleted, "")) + assert.NotEmpty(t, getRecord(t, ledger, 1).Decision.Snapshot) + }) +} + +// The conversation read uses the conversation index rather than scanning +// every record in a live state. +func TestTheConversationReadUsesItsIndex(t *testing.T) { + ledger := newTestLedger(t) + var plan strings.Builder + rows, err := ledger.db.QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+liveConversation, "recording:9", "admitted", "dispatched") + require.NoError(t, err) + defer rows.Close() + for rows.Next() { + var id, parent, unused int + var detail string + require.NoError(t, rows.Scan(&id, &parent, &unused, &detail)) + plan.WriteString(detail + "\n") + } + require.NoError(t, rows.Err()) + assert.Contains(t, plan.String(), "events_conversation") +} + +// Two fetchers deciding one event at once: exactly one verdict is written. +func TestAdmissionConcurrentDecisionsWriteOne(t *testing.T) { + ledger := newTestLedger(t) + seenRecord(t, ledger, 1) + store := ledger.Admission() + + const racers = 8 + results := make([]error, racers) + var wg sync.WaitGroup + for i := range racers { + wg.Go(func() { + v := admittedVerdict(1, 0, "recording:9") + if i%2 == 1 { + v = blockedVerdict(1, 0, admission.ReasonReadFailed) + } + _, results[i] = store.Commit(context.Background(), v) + }) + } + wg.Wait() + + applied := 0 + for _, err := range results { + if err == nil { + applied++ + continue + } + require.ErrorIs(t, err, admission.ErrAlreadyDecided) + } + assert.Equal(t, 1, applied) + assert.Equal(t, int64(1), getRecord(t, ledger, 1).Revision) +} + +// Retention takes the verdict's content with the pointer's. +func TestDropContentTakesTheVerdictToo(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + at := time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC) + ledger.now = func() time.Time { return at } + seenRecord(t, ledger, 1) + _, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9")) + require.NoError(t, err) + require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, "")) + require.NoError(t, ledger.SetState(ctx, 1, StateCompleted, "")) + + dropped, err := ledger.DropContent(ctx, at.Add(time.Hour), at.Add(time.Hour)) + require.NoError(t, err) + require.Equal(t, 1, dropped) + + record := getRecord(t, ledger, 1) + assert.Equal(t, StateCompleted, record.State) + assert.Equal(t, Decision{DecidedAt: record.Decision.DecidedAt}, record.Decision, + "only the tombstone's timestamps survive") +} + +// Migration 4 carries a ledger written before it: its records load at +// revision 0 and are decided like any other. +func TestMigrationFourCarriesAnEarlierLedger(t *testing.T) { + path := t.TempDir() + "/state/connector.db" + all := migrations + migrations = all[:3] + old, err := OpenLedger(path) + migrations = all + require.NoError(t, err) + _, err = old.RecordSeen(context.Background(), testEvent(1), LanePoll) + require.NoError(t, err) + require.NoError(t, old.Close()) + + ledger, err := OpenLedger(path) + require.NoError(t, err) + t.Cleanup(func() { _ = ledger.Close() }) + version, err := ledger.SchemaVersion(context.Background()) + require.NoError(t, err) + assert.Equal(t, 4, version) + + ev, ok, err := ledger.Admission().LoadUndecided(context.Background(), 1) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, int64(0), ev.Revision) + _, err = ledger.Admission().Commit(context.Background(), admittedVerdict(1, ev.Revision, "recording:9")) + require.NoError(t, err) + assert.Equal(t, StateAdmitted, getRecord(t, ledger, 1).State) +} + +// The handoff end to end: intake records and offers, admission takes from the +// same queue, decides against the real ledger, and reports on the shared +// writer without content. +func TestRunAdmissionDecidesWhatIntakeHandsOver(t *testing.T) { + ledger := newTestLedger(t) + queue, err := NewQueue(10, 100) + require.NoError(t, err) + var out bytes.Buffer + lines := ndjson.NewWriter(&out) + + const secret = "the instruction itself" + reads := &adapterReads{summaries: map[int64]*basecamp.RecordingSummary{}} + for _, id := range []int64{501, 502} { + reads.summaries[id] = &basecamp.RecordingSummary{ + ID: id, Status: "active", Type: "Todo", Title: "A to-do", + AppURL: "https://app.basecamp.com/2914079/buckets/48699913/todos/" + strconv.FormatInt(id, 10), + Bucket: &basecamp.Bucket{ID: adapterBucketID}, + Creator: &basecamp.Person{ID: adapterOperatorID}, + Content: mentionMarkup(adapterAgentID) + secret, + MentionedPersonIDs: []int64{adapterAgentID}, + UpdatedAt: time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC), + } + } + admitter, err := admission.NewAdmitter(admission.Policy{ + AgentID: adapterAgentID, + Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, + Projects: map[int64]admission.Route{adapterBucketID: {Path: "/work/connector", Class: "internal"}}, + }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- RunAdmission(ctx, AdmissionOptions{Ledger: ledger, Queue: queue, Admitter: admitter, Workers: 2, Lines: lines}) + }() + + // Two to-dos, then a third event on the first one's conversation: a + // to-do's conversation is itself, so the third queues behind the first. + for i, recording := range []int64{501, 502, 501} { + ev := testEvent(int64(i + 1)) + ev.EventType, ev.Kind, ev.RecordingID = "todo.created", "todo_created", recording + _, err := ledger.RecordSeen(ctx, ev, LanePoll) + require.NoError(t, err) + require.NoError(t, queue.Offer(ctx, ev.ID)) + require.Eventually(t, func() bool { + return getRecord(t, ledger, ev.ID).State != StateSeen + }, 5*time.Second, 10*time.Millisecond) + } + cancel() + require.NoError(t, <-done) + + assert.Equal(t, StateAdmitted, getRecord(t, ledger, 1).State) + assert.Equal(t, StateAdmitted, getRecord(t, ledger, 2).State) + third := getRecord(t, ledger, 3) + assert.Equal(t, StateQueued, third.State) + assert.Contains(t, string(third.Decision.Snapshot), secret, "the queued follow-up keeps its instruction") + + assert.NotContains(t, out.String(), secret, "no content on the wire") + // Two workers: a verdict is visible in the ledger before its line is + // written, so lines arrive in no promised order. What is promised is one + // whole line per verdict, reporting the state the ledger wrote. + states := map[int64]admission.State{} + for _, raw := range strings.Split(strings.TrimSpace(out.String()), "\n") { + var line admission.Line + require.NoError(t, json.Unmarshal([]byte(raw), &line), "every line is whole JSON: %q", raw) + _, dup := states[line.EventID] + require.False(t, dup, "one line per verdict, event %d twice", line.EventID) + states[line.EventID] = line.State + } + assert.Equal(t, map[int64]admission.State{ + 1: admission.StateAdmitted, + 2: admission.StateAdmitted, + 3: admission.StateQueued, + }, states, "the line reports what the ledger wrote") +} + +func TestRunAdmissionNeedsTheLedgerAndQueue(t *testing.T) { + admitter, err := admission.NewAdmitter(admission.Policy{ + AgentID: adapterAgentID, + Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, + }, admission.Reads{Summaries: &adapterReads{}, Subscriptions: &adapterReads{}, Assignments: &adapterReads{}}) + require.NoError(t, err) + queue, err := NewQueue(1, 1) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + for name, opts := range map[string]AdmissionOptions{ + "no ledger": {Queue: queue, Admitter: admitter}, + "no queue": {Ledger: newTestLedger(t), Admitter: admitter}, + } { + t.Run(name, func(t *testing.T) { + err := RunAdmission(ctx, opts) + require.Error(t, err) + assert.NotErrorIs(t, err, context.DeadlineExceeded, "refused at once, not run until the deadline") + }) + } +} + +// adapterReads answers admission's reads for the handoff test. +type adapterReads struct { + summaries map[int64]*basecamp.RecordingSummary +} + +func (r *adapterReads) Summarize(_ context.Context, ref basecamp.RecordingRef) (*basecamp.RecordingSummary, error) { + s, ok := r.summaries[ref.RecordingID] + if !ok { + return nil, &basecamp.Error{Code: basecamp.CodeNotFound, Message: "not found"} + } + copied := *s + return &copied, nil +} + +func (r *adapterReads) Subscribed(context.Context, int64) (bool, error) { return false, nil } + +func (r *adapterReads) AddedPersonIDs(context.Context, int64, int64) ([]int64, bool, error) { + return nil, false, nil +} + +// mentionMarkup is a mention attachment naming id only inside its sgid, the +// way Basecamp renders one. +func mentionMarkup(id int64) string { + payload := `{"_rails":{"data":"gid://bc3/Person/` + strconv.FormatInt(id, 10) + `","pur":"attachable"}}` + sgid := base64.RawURLEncoding.EncodeToString([]byte(payload)) + return `` +} diff --git a/internal/connector/ledger_events.go b/internal/connector/ledger_events.go index 1867f7213..54d6f3281 100644 --- a/internal/connector/ledger_events.go +++ b/internal/connector/ledger_events.go @@ -33,6 +33,43 @@ type Record struct { SeenAt time.Time UpdatedAt time.Time ContentDropped bool + + // Revision counts every state write applied to the record since intake + // recorded it, a repeat of the state it already has included. A decision + // applies only at the revision it loaded. + Revision int64 + // Decision is admission's latest verdict on the record; its zero value + // until one is written. + Decision Decision +} + +// Decision is what admission wrote onto a record with its verdict. The pointer +// fields stay intake's; these are admission's, and dispatch starts a task from +// them. +type Decision struct { + // DecidedAt is when the latest verdict was written. + DecidedAt *time.Time + // BlockedAt is when the record entered its current run of blocked + // verdicts; nil unless it is blocked. + BlockedAt *time.Time + // RetryAt is a throttled verdict's server deadline; nil otherwise. + RetryAt *time.Time + + Trigger string + Acknowledge bool + ConversationKey string + ReplyKind string + ReplyRecordingID int64 + Routed bool + Route string + Class string + RecordingURL string + RequesterID int64 + // Snapshot is the recording's content as admission read it, JSON. An + // admitted verdict writes it, as admitted or queued; it stays through + // dispatch and completion until retention drops it, and any move to + // blocked or discarded clears it. + Snapshot json.RawMessage } // EffectivePerformer is the id the performers/exclude_performers filters @@ -168,8 +205,14 @@ func (l *Ledger) CountInState(ctx context.Context, state RecordState) (int, erro // edges back into the working states. Completed and discarded have none. var lifecycle = map[RecordState][]RecordState{ // seen to queued is one edge, not two: admission commits an admitted - // verdict AS queued when the conversation is already live, so the record - // never passes through admitted at all. + // verdict AS queued when the conversation is already live — another + // record on it admitted or dispatched, admission.Ledger's definition — so + // the record never passes through admitted at all. Deciding that moves + // only the incoming record; a queued record leaves queued by dispatch. + // + // A blocked record's edges back into the working states are for the + // lifecycle's own bookkeeping. It returns to work with content only + // through a new verdict, because a move to blocked drops the snapshot. StateSeen: {StateAdmitted, StateQueued, StateBlocked, StateDiscarded}, StateAdmitted: {StateQueued, StateDispatched, StateBlocked, StateDiscarded}, StateQueued: {StateDispatched, StateBlocked, StateDiscarded}, @@ -211,47 +254,135 @@ var ErrNotATransition = errors.New("not a transition the ledger's lifecycle allo // two statements is a race, and this is the guarantee that a finished record // stays finished. func (l *Ledger) SetState(ctx context.Context, id int64, state RecordState, reason string) error { - switch state { + moved, err := l.move(ctx, l.db, transition{id: id, state: state, reason: reason}) + if err != nil { + return err + } + if !moved { + return l.explainRefusal(ctx, id, state) + } + return nil +} + +// dbtx is what a transition runs against: the ledger's handle, or a +// transaction a caller already holds so the move commits with its other +// writes. +type dbtx interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// transition is one state change and what is written with it. +type transition struct { + id int64 + state RecordState + reason string + // from narrows the states the record may be leaving. Empty means every + // state the lifecycle lets reach state; a narrowing can only remove edges, + // never add one. + from []RecordState + // revision, when set, applies the move only while the record is still at + // that revision. + revision *int64 + // retryAt is a blocked record's not-before deadline, stored as retry_at; + // the zero time stores none. Only a move to blocked may carry one. + retryAt time.Time + // set is further columns written in the same statement. + set []assignment +} + +// assignment is one further column a transition writes. column is this +// package's own constant, never input; value is bound. +type assignment struct { + column string + value any +} + +// move is the ledger's one state-changing write. Every change of state goes +// through it, so every change meets the lifecycle, bumps the revision, and +// keeps the retention clock where a repeat must leave it. It reports whether +// the record moved; a refusal is the caller's to explain. +// +// revision is bumped on every applied write, a repeat included. It is what a +// decision loaded earlier is compared against, and any write since that load +// is a reason the decision may no longer hold. +func (l *Ledger) move(ctx context.Context, db dbtx, t transition) (bool, error) { + switch t.state { case StateBlocked, StateDiscarded: - if reason == "" { - return fmt.Errorf("connector: set state of %d: a %s record needs a reason", id, state) + if t.reason == "" { + return false, fmt.Errorf("connector: set state of %d: a %s record needs a reason", t.id, t.state) } case StateSeen, StateAdmitted, StateQueued, StateDispatched, StateCompleted: - if reason != "" { - return fmt.Errorf("connector: set state of %d: a %s record takes no reason", id, state) + if t.reason != "" { + return false, fmt.Errorf("connector: set state of %d: a %s record takes no reason", t.id, t.state) } default: // A state outside the lifecycle is a row no recovery scan looks for. - return fmt.Errorf("connector: set state of %d: %q is not a ledger state", id, state) + return false, fmt.Errorf("connector: set state of %d: %q is not a ledger state", t.id, t.state) } - froms := enterableFrom(state) - args := []any{string(state), reason, string(state), l.timestamp(), id} - for _, from := range froms { - args = append(args, from) + if !t.retryAt.IsZero() && t.state != StateBlocked { + return false, fmt.Errorf("connector: set state of %d: only a blocked record has a retry deadline", t.id) } - // The only thing concatenated is a list of "?" as long as the lifecycle's - // own edge list. Every value is bound. - //nolint:gosec // G202: placeholders, not values - // + froms := enterableFrom(t.state) + if len(t.from) > 0 { + froms = slices.DeleteFunc(froms, func(from string) bool { + return !slices.Contains(t.from, RecordState(from)) + }) + } + // updated_at is left alone when the state does not change. It is the // retention clock DropContent reads, and a repeated write of the state a // record already has would silently restart the window on a finished - // record. - query := `UPDATE events SET state = ?, reason = ?, - updated_at = CASE WHEN state = ? THEN updated_at ELSE ? END -WHERE id = ? AND state IN (` + strings.TrimSuffix(strings.Repeat("?, ", len(froms)), ", ") + `)` - res, err := l.db.ExecContext(ctx, query, args...) + // record. Every right-hand side reads the row as it was before this + // statement, which is SQLite's rule for UPDATE. + // + // The blocked schedule's inputs are the move's too, so no path into or out + // of blocked can leave them stale. blocked_at is when the record entered + // its current run of blocked states — cleared on leaving, so it is set + // exactly on entering and kept across a repeat — because the retry + // window counts from it. + // retry_at holds only for the move that set it. And a record moving to + // blocked or discarded loses its snapshot: only a record on its way to a + // worker carries content. + now := l.timestamp() + var retryAt any + if !t.retryAt.IsZero() { + retryAt = stamp(t.retryAt) + } + var query strings.Builder + query.WriteString(`UPDATE events SET state = ?, reason = ?, revision = revision + 1, + updated_at = CASE WHEN state = ? THEN updated_at ELSE ? END, + blocked_at = CASE WHEN ? <> 'blocked' THEN NULL ELSE COALESCE(blocked_at, ?) END, + retry_at = ?, + snapshot = CASE WHEN ? IN ('blocked', 'discarded') THEN NULL ELSE snapshot END`) + args := []any{string(t.state), t.reason, string(t.state), now, string(t.state), now, retryAt, string(t.state)} + for _, a := range t.set { + query.WriteString(", " + a.column + " = ?") + args = append(args, a.value) + } + query.WriteString(" WHERE id = ?") + args = append(args, t.id) + if t.revision != nil { + query.WriteString(" AND revision = ?") + args = append(args, *t.revision) + } + query.WriteString(" AND state IN (" + strings.TrimSuffix(strings.Repeat("?, ", len(froms)), ", ") + ")") + for _, from := range froms { + args = append(args, from) + } + + // Concatenated are column names this package declares, + // and a list of "?" as long as the lifecycle's own edge list. Every value + // is bound. + res, err := db.ExecContext(ctx, query.String(), args...) //nolint:gosec // G202: constants and placeholders, not values if err != nil { - return fmt.Errorf("connector: set state of %d: %w", id, err) + return false, fmt.Errorf("connector: set state of %d: %w", t.id, err) } affected, err := res.RowsAffected() if err != nil { - return fmt.Errorf("connector: set state of %d: %w", id, err) + return false, fmt.Errorf("connector: set state of %d: %w", t.id, err) } - if affected == 0 { - return l.explainRefusal(ctx, id, state) - } - return nil + return affected > 0, nil } // explainRefusal says why an update changed nothing: there is no such record, @@ -286,7 +417,10 @@ func (l *Ledger) DropContent(ctx context.Context, discardedBefore, completedBefo UPDATE events SET details = NULL, event_type = '', kind = '', action = '', bucket_id = 0, creator_id = 0, performed_by_id = NULL, recording_id = 0, actor_type = '', - visible_to_clients = NULL, content_dropped = 1, updated_at = updated_at + visible_to_clients = NULL, content_dropped = 1, updated_at = updated_at, + snapshot = NULL, trigger_name = '', acknowledge = 0, conversation_key = '', + reply_kind = '', reply_recording_id = 0, routed = 0, route = '', class = '', + recording_url = '', requester_id = 0 WHERE content_dropped = 0 AND ((state = ? AND updated_at < ?) OR (state = ? AND updated_at < ?))`, string(StateDiscarded), stamp(discardedBefore), @@ -304,7 +438,10 @@ WHERE content_dropped = 0 const selectRecords = ` SELECT id, state, reason, lane, event_type, kind, action, bucket_id, creator_id, performed_by_id, recording_id, details, actor_type, visible_to_clients, - created_at, seen_at, updated_at, content_dropped + created_at, seen_at, updated_at, content_dropped, revision, decided_at, + blocked_at, retry_at, trigger_name, acknowledge, conversation_key, + reply_kind, reply_recording_id, routed, route, class, recording_url, + requester_id, snapshot FROM events` func scanRecords(rows *sql.Rows) ([]Record, error) { @@ -320,11 +457,19 @@ func scanRecords(rows *sql.Rows) ([]Record, error) { contentDropped int performedBy sql.NullInt64 visibleToClients sql.NullBool + decidedAt, blockedAt sql.NullString + retryAt sql.NullString + acknowledge, routed int + snapshot []byte + d = &r.Decision ) if err := rows.Scan(&r.ID, &state, &r.Reason, &lane, &r.EventType, &r.Kind, &r.Action, &r.BucketID, &r.CreatorID, &performedBy, &r.RecordingID, &details, &r.ActorType, &visibleToClients, &createdAt, &seenAt, - &updatedAt, &contentDropped); err != nil { + &updatedAt, &contentDropped, &r.Revision, &decidedAt, &blockedAt, + &retryAt, &d.Trigger, &acknowledge, &d.ConversationKey, &d.ReplyKind, + &d.ReplyRecordingID, &routed, &d.Route, &d.Class, &d.RecordingURL, + &d.RequesterID, &snapshot); err != nil { return nil, fmt.Errorf("connector: scan event record: %w", err) } r.State = RecordState(state) @@ -351,6 +496,23 @@ func scanRecords(rows *sql.Rows) ([]Record, error) { return nil, err } r.ContentDropped = contentDropped != 0 + d.Acknowledge, d.Routed = acknowledge != 0, routed != 0 + if len(snapshot) > 0 { + d.Snapshot = json.RawMessage(snapshot) + } + for _, stamped := range []struct { + raw sql.NullString + to **time.Time + }{{decidedAt, &d.DecidedAt}, {blockedAt, &d.BlockedAt}, {retryAt, &d.RetryAt}} { + if !stamped.raw.Valid { + continue + } + at, err := parseStamp(stamped.raw.String) + if err != nil { + return nil, err + } + *stamped.to = &at + } records = append(records, r) } return records, rows.Err()