feat: apply lifecycle events atomically

This commit is contained in:
2026-09-06 09:39:41 +00:00
parent 214d4e43d2
commit ebbbcb6aff
3 changed files with 46 additions and 6 deletions
+4
View File
@@ -275,6 +275,10 @@ func eventAppendFromWire(event *rvboxv1.CommandEvent, clientID string, generatio
var immutable [32]byte var immutable [32]byte
copy(immutable[:], event.GetImmutableEventSha256()) copy(immutable[:], event.GetImmutableEventSha256())
result := store.EventAppend{IssueUUID: [16]byte(issue), ClientID: clientID, SessionGeneration: generation, EventSeq: event.GetEventSeq(), ObservedUnixNano: event.GetObservedAt().AsTime().UnixNano(), ReceiptUnixNano: receipt.UnixNano(), EventType: eventType(event), Compression: 1, RawLength: uint64(len(payload)), Payload: payload, ImmutableSHA256: immutable} result := store.EventAppend{IssueUUID: [16]byte(issue), ClientID: clientID, SessionGeneration: generation, EventSeq: event.GetEventSeq(), ObservedUnixNano: event.GetObservedAt().AsTime().UnixNano(), ReceiptUnixNano: receipt.UnixNano(), EventType: eventType(event), Compression: 1, RawLength: uint64(len(payload)), Payload: payload, ImmutableSHA256: immutable}
if lifecycle := event.GetLifecycle(); lifecycle != nil {
value := lifecycle.GetLifecycle()
result.Lifecycle, result.LifecycleRevision = &value, lifecycle.GetCommandRevision()
}
if output := event.GetOutput(); output != nil { if output := event.GetOutput(); output != nil {
result.Stream = uint16(output.GetStream()) result.Stream = uint16(output.GetStream())
result.Output = true result.Output = true
+31 -6
View File
@@ -9,6 +9,9 @@ import (
"io/fs" "io/fs"
"math" "math"
"path/filepath" "path/filepath"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/domain"
) )
const ( const (
@@ -44,6 +47,8 @@ type EventAppend struct {
RawLength uint64 RawLength uint64
Payload []byte Payload []byte
ImmutableSHA256 [32]byte ImmutableSHA256 [32]byte
Lifecycle *rvboxv1.CommandLifecycle
LifecycleRevision uint64
Output bool Output bool
UseCloseout bool UseCloseout bool
} }
@@ -81,10 +86,12 @@ func (store *Store) AppendCommandEvent(ctx context.Context, event EventAppend) (
return result, err return result, err
} }
var lastSequence, commandOutputCharged, commandCharged, clientCharged, serverCharged, closeoutRemaining uint64 var lastSequence, commandOutputCharged, commandCharged, clientCharged, serverCharged, closeoutRemaining uint64
var currentLifecycle uint32
var currentRevision uint64
var clientID string var clientID string
query := `SELECT commands.last_event_seq, commands.output_charged_bytes, query := `SELECT commands.last_event_seq, commands.output_charged_bytes,
commands.charged_bytes, commands.closeout_remaining_bytes, commands.client_id, clients.charged_bytes, commands.charged_bytes, commands.closeout_remaining_bytes, commands.client_id, clients.charged_bytes,
storage_counters.command_charged_bytes storage_counters.command_charged_bytes, commands.lifecycle, commands.revision
FROM commands JOIN clients ON clients.client_id = commands.client_id FROM commands JOIN clients ON clients.client_id = commands.client_id
JOIN storage_counters ON storage_counters.singleton = 1 WHERE commands.issue_uuid = ?` JOIN storage_counters ON storage_counters.singleton = 1 WHERE commands.issue_uuid = ?`
arguments := []any{event.IssueUUID[:]} arguments := []any{event.IssueUUID[:]}
@@ -97,7 +104,7 @@ JOIN storage_counters ON storage_counters.singleton = 1 WHERE commands.issue_uui
arguments = append(arguments, event.SessionGeneration) arguments = append(arguments, event.SessionGeneration)
} }
err = database.QueryRowContext(ctx, query, arguments...).Scan( err = database.QueryRowContext(ctx, query, arguments...).Scan(
&lastSequence, &commandOutputCharged, &commandCharged, &closeoutRemaining, &clientID, &clientCharged, &serverCharged) &lastSequence, &commandOutputCharged, &commandCharged, &closeoutRemaining, &clientID, &clientCharged, &serverCharged, &currentLifecycle, &currentRevision)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return result, ErrCommandNotFound return result, ErrCommandNotFound
} }
@@ -117,6 +124,14 @@ JOIN storage_counters ON storage_counters.singleton = 1 WHERE commands.issue_uui
if event.EventSeq != lastSequence+1 { if event.EventSeq != lastSequence+1 {
return result, ErrEventSequenceGap return result, ErrEventSequenceGap
} }
if event.Lifecycle != nil {
if event.LifecycleRevision == 0 || event.LifecycleRevision != currentRevision {
return result, errors.New("lifecycle event revision does not match command")
}
if !domain.CanTransition(rvboxv1.CommandLifecycle(currentLifecycle), *event.Lifecycle) {
return result, domain.ValidateTransition(rvboxv1.CommandLifecycle(currentLifecycle), *event.Lifecycle)
}
}
rowsCharged, indexesCharged := uint64(1), uint64(1) rowsCharged, indexesCharged := uint64(1), uint64(1)
if store.willCreateSegment(event.IssueUUID, uint64(len(encoded))) { if store.willCreateSegment(event.IssueUUID, uint64(len(encoded))) {
rowsCharged++ rowsCharged++
@@ -197,11 +212,21 @@ payload, segment_ordinal, segment_record_offset, segment_record_length, immutabl
} }
if err == nil { if err == nil {
var update sql.Result var update sql.Result
update, err = tx.ExecContext(ctx, `UPDATE commands SET last_event_seq = ?, commandUpdate := `UPDATE commands SET last_event_seq = ?,
retained_compressed_bytes = retained_compressed_bytes + ?, output_charged_bytes = ?, charged_bytes = ?, retained_compressed_bytes = retained_compressed_bytes + ?, output_charged_bytes = ?, charged_bytes = ?,
closeout_remaining_bytes = ? WHERE issue_uuid = ? AND last_event_seq = ?`, event.EventSeq, len(event.Payload), closeout_remaining_bytes = ?`
reservation.CommandOutputCharged, reservation.CommandTotalCharged, reservation.CloseoutRemaining, commandArgs := []any{event.EventSeq, len(event.Payload), reservation.CommandOutputCharged, reservation.CommandTotalCharged, reservation.CloseoutRemaining}
event.IssueUUID[:], lastSequence) if event.Lifecycle != nil {
terminal := 0
if domain.IsTerminal(*event.Lifecycle) {
terminal = 1
}
commandUpdate = `UPDATE commands SET lifecycle = ?, terminal_time = CASE WHEN ? = 1 THEN ? ELSE terminal_time END, ` + commandUpdate[len("UPDATE commands SET "):]
commandArgs = append([]any{uint32(*event.Lifecycle), terminal, event.ReceiptUnixNano}, commandArgs...)
}
commandUpdate += ` WHERE issue_uuid = ? AND last_event_seq = ?`
commandArgs = append(commandArgs, event.IssueUUID[:], lastSequence)
update, err = tx.ExecContext(ctx, commandUpdate, commandArgs...)
if err == nil { if err == nil {
var affected int64 var affected int64
affected, err = update.RowsAffected() affected, err = update.RowsAffected()
@@ -109,6 +109,13 @@ func TestWebSocketDispatchAfterReconciliation_HP_DISPATCH_03(t *testing.T) {
if dispatch == nil || dispatch.GetIssueUuid() != issue.String() || dispatch.GetCommandRevision() != 1 || dispatch.GetTargetSessionGeneration() != accepted.Generation || !proto.Equal(dispatch.GetSpec(), spec) || string(dispatch.GetImmutableRequestSha256()) != string(requestHash[:]) { if dispatch == nil || dispatch.GetIssueUuid() != issue.String() || dispatch.GetCommandRevision() != 1 || dispatch.GetTargetSessionGeneration() != accepted.Generation || !proto.Equal(dispatch.GetSpec(), spec) || string(dispatch.GetImmutableRequestSha256()) != string(requestHash[:]) {
t.Fatalf("dispatch = %#v", dispatch) t.Fatalf("dispatch = %#v", dispatch)
} }
acceptedEnvelope, err := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: accepted.ID, SessionGeneration: accepted.Generation, Payload: &rvboxv1.AgentEnvelope_CommandAccepted{CommandAccepted: &rvboxv1.CommandAccepted{IssueUuid: issue.String(), CommandRevision: 1, Accepted: true}}})
if err != nil {
t.Fatal(err)
}
if err := transport.Write(ctx, acceptedEnvelope); err != nil {
t.Fatal(err)
}
event := &rvboxv1.CommandEvent{IssueUuid: issue.String(), EventSeq: 1, ObservedAt: timestamppb.Now(), Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle_COMMAND_RUNNING, CommandRevision: 1}}} event := &rvboxv1.CommandEvent{IssueUuid: issue.String(), EventSeq: 1, ObservedAt: timestamppb.Now(), Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle_COMMAND_RUNNING, CommandRevision: 1}}}
if err := agent.SendCommandEvent(ctx, transport, accepted, event, agentproto.DefaultLimits()); err != nil { if err := agent.SendCommandEvent(ctx, transport, accepted, event, agentproto.DefaultLimits()); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -125,4 +132,8 @@ func TestWebSocketDispatchAfterReconciliation_HP_DISPATCH_03(t *testing.T) {
if err := persistence.DB().QueryRow(`SELECT count(*) FROM command_events WHERE issue_uuid = ?`, issue[:]).Scan(&persistedEvents); err != nil || persistedEvents != 1 { if err := persistence.DB().QueryRow(`SELECT count(*) FROM command_events WHERE issue_uuid = ?`, issue[:]).Scan(&persistedEvents); err != nil || persistedEvents != 1 {
t.Fatalf("persisted events = %d, %v", persistedEvents, err) t.Fatalf("persisted events = %d, %v", persistedEvents, err)
} }
var lifecycle uint32
if err := persistence.DB().QueryRow(`SELECT lifecycle FROM commands WHERE issue_uuid = ?`, issue[:]).Scan(&lifecycle); err != nil || lifecycle != uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING) {
t.Fatalf("command lifecycle = %d, %v", lifecycle, err)
}
} }