feat: execute durable client commands through supervisor
This commit is contained in:
@@ -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(¤t, &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 {
|
||||
|
||||
Reference in New Issue
Block a user