feat: execute durable client commands through supervisor

This commit is contained in:
2026-09-06 12:18:15 +00:00
parent 3f84d3b2f1
commit 56b15c7f4f
41 changed files with 4272 additions and 66 deletions
+198 -1
View File
@@ -8,9 +8,13 @@ import (
"errors"
"fmt"
"time"
"unicode/utf8"
"github.com/klauspost/compress/zstd"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
type Command struct {
@@ -23,6 +27,11 @@ type Command struct {
// server. It is retained as raw protobuf bytes so the runtime can validate
// and execute exactly the admitted request after a restart.
ExecutionSpec []byte
// Script carries the immutable descriptor reservation for a script-backed
// command. Its bytes arrive later through AppendScriptChunk; creating the
// descriptor row in the same transaction as acceptance prevents an
// accepted command from being left without upload state after a crash.
Script *ScriptDescriptor
}
type Acceptance struct {
@@ -63,7 +72,7 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
return Acceptance{}, errors.New("invalid command acceptance")
}
var storedSpec []byte
var specCharge uint64
var specCharge, scriptCharge uint64
if len(command.ExecutionSpec) > 0 {
if uint64(len(command.ExecutionSpec)) > store.maxExecutionSpecBytes {
return Acceptance{}, errors.New("execution specification exceeds client limit")
@@ -78,6 +87,21 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
return Acceptance{}, err
}
}
var storedScript []byte
if command.Script != nil {
if err := validateAcceptedScript(command.ExecutionSpec, *command.Script, store.maxScriptBytes); err != nil {
return Acceptance{}, err
}
var err error
storedScript, err = compressScript(nil)
if err != nil {
return Acceptance{}, err
}
scriptCharge, err = EstimateCharge(ChargeInput{EncodedBytes: uint64(len(storedScript)), SQLiteRows: 1, IndexEntries: 1})
if err != nil {
return Acceptance{}, err
}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return Acceptance{}, err
@@ -116,6 +140,9 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
return Acceptance{}, err
}
charge, overflow := addChecked(baseCharge, specCharge)
if !overflow {
charge, overflow = addChecked(charge, scriptCharge)
}
if overflow {
return Acceptance{}, &CapacityError{Tier: CapacityTierHardMaximum, Requested: ^uint64(0), Available: store.quotaLimits.HardAllocationBytes}
}
@@ -136,12 +163,32 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
return Acceptance{}, err
}
}
if command.Script != nil {
if _, err := tx.ExecContext(ctx, `INSERT INTO scripts(issue_uuid, declared_raw_bytes, declared_sha256, stored_bytes, compression, stored_data, charged_bytes) VALUES (?, ?, ?, ?, 2, ?, ?)`, command.IssueUUID[:], command.Script.SizeBytes, command.Script.SHA256[:], len(storedScript), storedScript, scriptCharge); err != nil {
return Acceptance{}, err
}
}
if err := tx.Commit(); err != nil {
return Acceptance{}, err
}
return Acceptance{Command: command}, nil
}
func validateAcceptedScript(encodedSpec []byte, descriptor ScriptDescriptor, maximum uint64) error {
if descriptor.SizeBytes > maximum {
return ErrScriptBounds
}
var spec rvboxv1.ExecutionSpec
if err := proto.Unmarshal(encodedSpec, &spec); err != nil {
return ErrScriptConflict
}
declared := spec.GetScript()
if declared == nil || declared.GetSizeBytes() != descriptor.SizeBytes || len(declared.GetSha256()) != sha256.Size || !bytes.Equal(declared.GetSha256(), descriptor.SHA256[:]) {
return ErrScriptConflict
}
return nil
}
// GetCommand returns the durable command metadata and its immutable execution
// specification. The returned protobuf bytes are a copy and can be decoded or
// modified by the runtime without changing the spool's source of truth.
@@ -340,6 +387,156 @@ func (store *Store) MarkTerminal(ctx context.Context, issueUUID domain.UUID, pha
return nil
}
// LaunchEvidence is the durable pre/post-authorization record used to fence
// uncertain OS launches across daemon restarts. PID is evidence only and is
// never sufficient for recovery-time signalling without a native creation
// identity check.
type LaunchEvidence struct {
Phase domain.LaunchPhase
Context string
PID uint32
}
func (store *Store) SetLaunchPhase(ctx context.Context, issueUUID domain.UUID, phase domain.LaunchPhase, contextName string, pid uint32) error {
if !validUUID(issueUUID) || phase > domain.LaunchPhaseAuthorized || len(contextName) > 128 || !utf8.ValidString(contextName) {
return errors.New("invalid launch barrier")
}
if phase == domain.LaunchPhaseNone {
contextName, pid = "", 0
}
result, err := store.db.ExecContext(ctx, `UPDATE commands SET launch_phase = ?, launch_context = ?, launch_pid = ? WHERE issue_uuid = ? AND terminal = 0`, uint32(phase), contextName, pid, issueUUID[:])
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 0 {
return ErrUnknownCommand
}
return nil
}
// RecoverLaunchUncertainty converts every command whose authorization barrier
// crossed before a restart into one durable interrupted terminal event. The
// Windows Job kill-on-close guarantee makes this safe: a surviving process is
// never redispatched, and recovery never signals a PID by itself.
func (store *Store) RecoverLaunchUncertainty(ctx context.Context, now time.Time) ([]domain.UUID, error) {
if now.IsZero() {
return nil, errors.New("launch recovery time is required")
}
rows, err := store.db.QueryContext(ctx, `SELECT issue_uuid, command_revision FROM commands WHERE launch_phase = 2 AND terminal = 0 ORDER BY accepted_at, issue_uuid`)
if err != nil {
return nil, err
}
type pending struct {
issue domain.UUID
revision uint64
}
var pendingRows []pending
for rows.Next() {
var encoded []byte
var revision uint64
if err := rows.Scan(&encoded, &revision); err != nil {
_ = rows.Close()
return nil, err
}
var issue domain.UUID
if len(encoded) != len(issue) {
_ = rows.Close()
return nil, ErrScriptState
}
copy(issue[:], encoded)
if !validUUID(issue) || revision == 0 {
_ = rows.Close()
return nil, ErrScriptState
}
pendingRows = append(pendingRows, pending{issue: issue, revision: revision})
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
interrupted := make([]domain.UUID, 0, len(pendingRows))
for _, item := range pendingRows {
if _, err := store.AppendLifecycle(ctx, item.issue, uint32(rvboxv1.CommandLifecycle_COMMAND_INTERRUPTED), item.revision, "uncertain launch recovered after daemon restart", now); err != nil {
return interrupted, err
}
interrupted = append(interrupted, item.issue)
}
return interrupted, nil
}
// AppendLifecycle atomically advances the durable command phase and appends
// its lifecycle event. Keeping these writes in one transaction prevents a
// crash between a terminal marker and its public event from creating a state
// that can be replayed as a second execution.
func (store *Store) AppendLifecycle(ctx context.Context, issueUUID domain.UUID, phase uint32, revision uint64, detail string, observedAt time.Time) (Event, error) {
if !validUUID(issueUUID) || phase == 0 || phase > 11 || observedAt.IsZero() || revision == 0 {
return Event{}, errors.New("invalid lifecycle event")
}
if len(detail) > 4096 || !utf8.ValidString(detail) {
return Event{}, errors.New("lifecycle detail is invalid or too large")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return Event{}, err
}
defer tx.Rollback()
var current uint32
var storedRevision uint64
var nextOrdinal, outputCharged, totalCharged, closeout uint64
if err := tx.QueryRowContext(ctx, `SELECT phase, command_revision, next_local_ordinal, output_charged_bytes, total_charged_bytes, closeout_remaining_bytes FROM commands WHERE issue_uuid = ?`, issueUUID[:]).Scan(&current, &storedRevision, &nextOrdinal, &outputCharged, &totalCharged, &closeout); err == sql.ErrNoRows {
return Event{}, ErrUnknownCommand
} else if err != nil {
return Event{}, err
}
if storedRevision != revision {
return Event{}, ErrCommandConflict
}
if current == phase {
return Event{}, nil
}
if !domain.CanTransition(rvboxv1.CommandLifecycle(current), rvboxv1.CommandLifecycle(phase)) {
return Event{}, fmt.Errorf("invalid lifecycle transition %s -> %s", rvboxv1.CommandLifecycle(current), rvboxv1.CommandLifecycle(phase))
}
lifecycle := &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle(phase), CommandRevision: revision, Detail: detail}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(&rvboxv1.CommandEvent{IssueUuid: issueUUID.String(), ObservedAt: timestamppb.New(observedAt), Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: lifecycle}})
if err != nil {
return Event{}, err
}
charge, err := EstimateCharge(ChargeInput{EncodedBytes: uint64(len(payload)), SQLiteRows: 1, IndexEntries: 2})
if err != nil {
return Event{}, err
}
clientTotal, err := clientTotalCharge(ctx, tx)
if err != nil {
return Event{}, err
}
decision, err := CheckReservation(store.quotaLimits, ReservationState{CommandOutputCharged: outputCharged, CommandTotalCharged: totalCharged, ClientTotalCharged: clientTotal, CloseoutRemaining: closeout}, ReservationRequest{ChargedBytes: charge, UseCloseout: isTerminalPhase(phase)})
if err != nil {
return Event{}, err
}
digest := immutableDigest(payload)
if _, err := tx.ExecContext(ctx, `INSERT INTO events(issue_uuid, local_ordinal, event_kind, compression, raw_bytes, charged_bytes, output, payload, payload_sha256, created_at) VALUES (?, ?, 4, 1, ?, ?, 0, ?, ?, ?)`, issueUUID[:], nextOrdinal, len(payload), charge, payload, digest[:], observedAt.UnixNano()); err != nil {
return Event{}, err
}
terminal := isTerminalPhase(phase)
if _, err := tx.ExecContext(ctx, `UPDATE commands SET phase = ?, terminal = ?, launch_phase = CASE WHEN ? = 1 THEN 0 ELSE launch_phase END, launch_context = CASE WHEN ? = 1 THEN '' ELSE launch_context END, launch_pid = CASE WHEN ? = 1 THEN 0 ELSE launch_pid END, next_local_ordinal = ?, total_charged_bytes = ?, closeout_remaining_bytes = ? WHERE issue_uuid = ?`, phase, boolInt(terminal), boolInt(terminal), boolInt(terminal), boolInt(terminal), nextOrdinal+1, decision.CommandTotalCharged, decision.CloseoutRemaining, issueUUID[:]); err != nil {
return Event{}, err
}
if err := updateClientTotalCharge(ctx, tx, decision.ClientTotalCharged); err != nil {
return Event{}, err
}
if err := tx.Commit(); err != nil {
return Event{}, err
}
return Event{IssueUUID: issueUUID, LocalOrdinal: nextOrdinal, Kind: 4, Compression: 1, RawBytes: uint64(len(payload)), Payload: append([]byte(nil), payload...), CreatedAt: observedAt}, nil
}
// CleanupTerminal moves a fully acknowledged terminal command into the compact
// tombstone ledger and removes all command-owned spool data in one transaction.
func (store *Store) CleanupTerminal(ctx context.Context, issueUUID domain.UUID, acknowledgedAt time.Time) error {