feat: persist and dispatch client command input

This commit is contained in:
2026-09-06 10:40:23 +00:00
parent 8155e5f81f
commit ad564de23f
16 changed files with 836 additions and 22 deletions
+99 -5
View File
@@ -1,12 +1,15 @@
package spool
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"time"
"github.com/klauspost/compress/zstd"
"github.com/rvbox/rvbox/internal/domain"
)
@@ -16,6 +19,10 @@ type Command struct {
Revision uint64
Phase uint32
Terminal bool
// ExecutionSpec is the deterministic protobuf payload received from the
// server. It is retained as raw protobuf bytes so the runtime can validate
// and execute exactly the admitted request after a restart.
ExecutionSpec []byte
}
type Acceptance struct {
@@ -55,12 +62,28 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
if !validUUID(command.IssueUUID) || command.Revision == 0 || command.Phase == 0 || command.Phase > 11 || command.Terminal != isTerminalPhase(command.Phase) || acceptedAt.IsZero() {
return Acceptance{}, errors.New("invalid command acceptance")
}
var storedSpec []byte
var specCharge uint64
if len(command.ExecutionSpec) > 0 {
if uint64(len(command.ExecutionSpec)) > store.maxExecutionSpecBytes {
return Acceptance{}, errors.New("execution specification exceeds client limit")
}
var err error
storedSpec, err = compressExecutionSpec(command.ExecutionSpec)
if err != nil {
return Acceptance{}, err
}
specCharge, err = EstimateCharge(ChargeInput{EncodedBytes: uint64(len(storedSpec)), SQLiteRows: 1, IndexEntries: 1})
if err != nil {
return Acceptance{}, err
}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return Acceptance{}, err
}
defer tx.Rollback()
existing, found, err := commandByUUID(ctx, tx, command.IssueUUID)
existing, found, err := commandByUUID(ctx, tx, command.IssueUUID, store.maxExecutionSpecBytes)
if err != nil {
return Acceptance{}, err
}
@@ -68,6 +91,9 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
if existing.ImmutableSHA256 != command.ImmutableSHA256 {
return Acceptance{}, ErrCommandConflict
}
if len(command.ExecutionSpec) > 0 && !bytes.Equal(existing.ExecutionSpec, command.ExecutionSpec) {
return Acceptance{}, ErrCommandConflict
}
return Acceptance{Duplicate: true, Command: existing}, nil
}
var tombstoneHash []byte
@@ -81,7 +107,7 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
if err != sql.ErrNoRows {
return Acceptance{}, err
}
charge, err := EstimateCharge(ChargeInput{SQLiteRows: 1, IndexEntries: 2})
baseCharge, err := EstimateCharge(ChargeInput{SQLiteRows: 1, IndexEntries: 2})
if err != nil {
return Acceptance{}, err
}
@@ -89,23 +115,51 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
if err != nil {
return Acceptance{}, err
}
charge, overflow := addChecked(baseCharge, specCharge)
if overflow {
return Acceptance{}, &CapacityError{Tier: CapacityTierHardMaximum, Requested: ^uint64(0), Available: store.quotaLimits.HardAllocationBytes}
}
decision, err := CheckReservation(store.quotaLimits, ReservationState{ClientTotalCharged: clientTotal, CloseoutRemaining: store.quotaLimits.CloseoutReserveBytes}, ReservationRequest{ChargedBytes: charge})
if err != nil {
return Acceptance{}, err
}
_, err = tx.ExecContext(ctx, `INSERT INTO commands(issue_uuid, immutable_sha256, command_revision, phase, terminal, base_charged_bytes, total_charged_bytes, closeout_remaining_bytes, accepted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.IssueUUID[:], command.ImmutableSHA256[:], command.Revision, command.Phase, boolInt(command.Terminal), charge, decision.CommandTotalCharged, decision.CloseoutRemaining, acceptedAt.UnixNano())
_, err = tx.ExecContext(ctx, `INSERT INTO commands(issue_uuid, immutable_sha256, command_revision, phase, terminal, base_charged_bytes, total_charged_bytes, closeout_remaining_bytes, accepted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.IssueUUID[:], command.ImmutableSHA256[:], command.Revision, command.Phase, boolInt(command.Terminal), baseCharge, decision.CommandTotalCharged, decision.CloseoutRemaining, acceptedAt.UnixNano())
if err != nil {
return Acceptance{}, err
}
if err := updateClientTotalCharge(ctx, tx, decision.ClientTotalCharged); err != nil {
return Acceptance{}, err
}
if len(storedSpec) > 0 {
digest := immutableDigest(storedSpec)
if _, err := tx.ExecContext(ctx, `INSERT INTO command_specs(issue_uuid, raw_bytes, stored_bytes, compression, payload, payload_sha256, charged_bytes) VALUES (?, ?, ?, 2, ?, ?, ?)`, command.IssueUUID[:], len(command.ExecutionSpec), len(storedSpec), storedSpec, digest[:], specCharge); err != nil {
return Acceptance{}, err
}
}
if err := tx.Commit(); err != nil {
return Acceptance{}, err
}
return Acceptance{Command: command}, 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.
func (store *Store) GetCommand(ctx context.Context, issueUUID domain.UUID) (Command, error) {
if !validUUID(issueUUID) {
return Command{}, ErrUnknownCommand
}
command, found, err := commandByUUID(ctx, store.db, issueUUID, store.maxExecutionSpecBytes)
if err != nil {
return Command{}, err
}
if !found {
return Command{}, ErrUnknownCommand
}
command.ExecutionSpec = bytes.Clone(command.ExecutionSpec)
return command, nil
}
// AppendEvent assigns command-local order only. EventSeq remains zero until
// AssignSendWindow makes the durable event eligible for a wire send.
func (store *Store) AppendEvent(ctx context.Context, issueUUID domain.UUID, input EventInput) (Event, error) {
@@ -448,11 +502,15 @@ func eventsBySequence(ctx context.Context, query queryer, issueUUID domain.UUID,
func commandByUUID(ctx context.Context, query interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, issueUUID domain.UUID) (Command, bool, error) {
}, issueUUID domain.UUID, maxSpecBytes uint64) (Command, bool, error) {
var command Command
var hash []byte
var terminal int
err := query.QueryRowContext(ctx, `SELECT immutable_sha256, command_revision, phase, terminal FROM commands WHERE issue_uuid = ?`, issueUUID[:]).Scan(&hash, &command.Revision, &command.Phase, &terminal)
var stored, digest []byte
var rawBytes, storedBytes, compression sql.NullInt64
err := query.QueryRowContext(ctx, `SELECT c.immutable_sha256, c.command_revision, c.phase, c.terminal,
s.raw_bytes, s.stored_bytes, s.compression, s.payload, s.payload_sha256
FROM commands c LEFT JOIN command_specs s ON s.issue_uuid = c.issue_uuid WHERE c.issue_uuid = ?`, issueUUID[:]).Scan(&hash, &command.Revision, &command.Phase, &terminal, &rawBytes, &storedBytes, &compression, &stored, &digest)
if err == sql.ErrNoRows {
return Command{}, false, nil
}
@@ -465,9 +523,45 @@ func commandByUUID(ctx context.Context, query interface {
copy(command.ImmutableSHA256[:], hash)
command.IssueUUID = issueUUID
command.Terminal = terminal != 0
if rawBytes.Valid {
computed := immutableDigest(stored)
if rawBytes.Int64 <= 0 || storedBytes.Int64 <= 0 || compression.Int64 != 2 || len(digest) != sha256.Size || uint64(rawBytes.Int64) > maxSpecBytes || uint64(storedBytes.Int64) != uint64(len(stored)) || !bytes.Equal(computed[:], digest) {
return Command{}, false, fmt.Errorf("invalid stored execution specification")
}
decoded, err := decompressExecutionSpec(stored, uint64(rawBytes.Int64), maxSpecBytes)
if err != nil {
return Command{}, false, err
}
command.ExecutionSpec = decoded
}
return command, true, nil
}
func compressExecutionSpec(raw []byte) ([]byte, error) {
encoder, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
if err != nil {
return nil, err
}
defer encoder.Close()
return encoder.EncodeAll(raw, nil), nil
}
func decompressExecutionSpec(stored []byte, rawBytes, maximum uint64) ([]byte, error) {
if rawBytes == 0 || rawBytes > maximum {
return nil, errors.New("invalid stored execution specification size")
}
decoder, err := zstd.NewReader(bytes.NewReader(stored), zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(maximum+1))
if err != nil {
return nil, errors.New("invalid stored execution specification")
}
defer decoder.Close()
decoded, err := decoder.DecodeAll(stored, nil)
if err != nil || uint64(len(decoded)) != rawBytes || uint64(len(decoded)) > maximum {
return nil, errors.New("invalid stored execution specification")
}
return decoded, nil
}
func tombstonesToTrim(ctx context.Context, tx *sql.Tx, limit uint64) (uint64, error) {
var count uint64
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM command_tombstones`).Scan(&count); err != nil {
+20 -1
View File
@@ -13,7 +13,10 @@ type migration struct {
sql string
}
var migrations = []migration{{version: 1, sql: schemaV1}}
var migrations = []migration{
{version: 1, sql: schemaV1},
{version: 2, sql: schemaV2},
}
func applyMigrations(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -113,3 +116,19 @@ CREATE TABLE command_tombstones (
) STRICT;
CREATE INDEX tombstones_fifo ON command_tombstones(acknowledged_at, issue_uuid);
`
// schemaV2 adds the immutable execution specification retained with every
// accepted dispatch. Keeping it in its own table lets old commands created by
// early development builds (which have no specification) remain readable and
// makes the payload's quota charge independently auditable.
const schemaV2 = `
CREATE TABLE command_specs (
issue_uuid BLOB PRIMARY KEY REFERENCES commands(issue_uuid) ON DELETE CASCADE CHECK(length(issue_uuid) = 16),
raw_bytes INTEGER NOT NULL CHECK(raw_bytes > 0),
stored_bytes INTEGER NOT NULL CHECK(stored_bytes > 0),
compression INTEGER NOT NULL CHECK(compression = 2),
payload BLOB NOT NULL,
payload_sha256 BLOB NOT NULL CHECK(length(payload_sha256) = 32),
charged_bytes INTEGER NOT NULL CHECK(charged_bytes > 0)
) STRICT, WITHOUT ROWID;
`
+33 -2
View File
@@ -19,6 +19,7 @@ var (
type RecoveryReport struct {
EventsChecked uint64
ScriptsChecked uint64
SpecsChecked uint64
}
// Check verifies state that SQLite's own structural check cannot express. The
@@ -34,7 +35,7 @@ func (store *Store) Check(ctx context.Context) (RecoveryReport, error) {
if err := checkQuotaCounters(ctx, store.db); err != nil {
return RecoveryReport{}, err
}
return checkPayloads(ctx, store.db, store.maxScriptBytes)
return checkPayloads(ctx, store.db, store.maxScriptBytes, store.maxExecutionSpecBytes)
}
func quickCheck(ctx context.Context, database *sql.DB) error {
@@ -65,6 +66,7 @@ func checkQuotaCounters(ctx context.Context, database *sql.DB) error {
err := database.QueryRowContext(ctx, `SELECT count(*) FROM commands AS c
WHERE c.total_charged_bytes != c.base_charged_bytes +
COALESCE((SELECT sum(charged_bytes) FROM events WHERE issue_uuid = c.issue_uuid), 0) +
COALESCE((SELECT charged_bytes FROM command_specs WHERE issue_uuid = c.issue_uuid), 0) +
COALESCE((SELECT charged_bytes FROM scripts WHERE issue_uuid = c.issue_uuid), 0)
OR c.output_charged_bytes !=
COALESCE((SELECT sum(charged_bytes) FROM events WHERE issue_uuid = c.issue_uuid AND output = 1), 0)`).Scan(&invalid)
@@ -81,7 +83,7 @@ OR c.output_charged_bytes !=
return nil
}
func checkPayloads(ctx context.Context, database *sql.DB, maxScriptBytes uint64) (RecoveryReport, error) {
func checkPayloads(ctx context.Context, database *sql.DB, maxScriptBytes, maxExecutionSpecBytes uint64) (RecoveryReport, error) {
var report RecoveryReport
rows, err := database.QueryContext(ctx, `SELECT issue_uuid, event_kind, raw_bytes, payload, payload_sha256 FROM events ORDER BY issue_uuid, local_ordinal`)
if err != nil {
@@ -119,6 +121,35 @@ func checkPayloads(ctx context.Context, database *sql.DB, maxScriptBytes uint64)
if err := rows.Close(); err != nil {
return report, err
}
specs, err := database.QueryContext(ctx, `SELECT issue_uuid, raw_bytes, stored_bytes, compression, payload, payload_sha256 FROM command_specs ORDER BY issue_uuid`)
if err != nil {
return report, err
}
for specs.Next() {
var owner, payload, digest []byte
var rawBytes, storedBytes uint64
var compression uint32
if err := specs.Scan(&owner, &rawBytes, &storedBytes, &compression, &payload, &digest); err != nil {
_ = specs.Close()
return report, err
}
computed := immutableDigest(payload)
if _, valid := copiedUUID(owner); !valid || compression != 2 || rawBytes == 0 || rawBytes > maxExecutionSpecBytes || storedBytes != uint64(len(payload)) || len(digest) != 32 || !bytes.Equal(computed[:], digest) {
_ = specs.Close()
return report, ErrStoredPayloadChecksum
}
if _, err := decompressExecutionSpec(payload, rawBytes, maxExecutionSpecBytes); err != nil {
_ = specs.Close()
return report, ErrStoredPayloadChecksum
}
report.SpecsChecked++
}
if err := specs.Close(); err != nil {
return report, err
}
if err := specs.Err(); err != nil {
return report, err
}
scripts, err := database.QueryContext(ctx, `SELECT issue_uuid, received_raw_bytes, stored_bytes, stored_data FROM scripts ORDER BY issue_uuid`)
if err != nil {
return report, err
+24 -10
View File
@@ -37,18 +37,23 @@ type Options struct {
BusyTimeout time.Duration
TombstoneLimit uint64
QuotaLimits QuotaLimits
MaxScriptBytes uint64
// MaxExecutionSpecBytes bounds the decompressed protobuf retained for an
// accepted command. It is separate from MaxScriptBytes because a script
// body is uploaded in a different table and lifecycle.
MaxExecutionSpecBytes uint64
MaxScriptBytes uint64
}
type Store struct {
db *sql.DB
unlock func() error
dataDir string
identity domain.UUID
tombstoneLimit uint64
quotaLimits QuotaLimits
maxScriptBytes uint64
mu sync.Mutex
db *sql.DB
unlock func() error
dataDir string
identity domain.UUID
tombstoneLimit uint64
quotaLimits QuotaLimits
maxScriptBytes uint64
maxExecutionSpecBytes uint64
mu sync.Mutex
}
// Open recovers an existing spool or creates an empty one. The caller must
@@ -76,6 +81,15 @@ func Open(ctx context.Context, options Options) (*Store, error) {
options.MaxScriptBytes = options.QuotaLimits.CommandTotalBytes
}
}
if options.MaxExecutionSpecBytes == 0 {
options.MaxExecutionSpecBytes = 768 << 10
if options.MaxExecutionSpecBytes > options.QuotaLimits.CommandTotalBytes {
options.MaxExecutionSpecBytes = options.QuotaLimits.CommandTotalBytes
}
}
if options.MaxExecutionSpecBytes > options.QuotaLimits.CommandTotalBytes {
return nil, errors.New("execution specification maximum exceeds client command quota")
}
if options.MaxScriptBytes > options.QuotaLimits.CommandTotalBytes {
return nil, errors.New("script maximum exceeds client command quota")
}
@@ -110,7 +124,7 @@ func Open(ctx context.Context, options Options) (*Store, error) {
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
store := &Store{db: db, unlock: unlock, dataDir: options.DataDir, identity: identity, tombstoneLimit: options.TombstoneLimit, quotaLimits: options.QuotaLimits, maxScriptBytes: options.MaxScriptBytes}
store := &Store{db: db, unlock: unlock, dataDir: options.DataDir, identity: identity, tombstoneLimit: options.TombstoneLimit, quotaLimits: options.QuotaLimits, maxScriptBytes: options.MaxScriptBytes, maxExecutionSpecBytes: options.MaxExecutionSpecBytes}
if err := db.PingContext(ctx); err != nil {
_ = store.Close()
return nil, fmt.Errorf("open client spool SQLite: %w", err)
+64
View File
@@ -1,6 +1,7 @@
package spool
import (
"bytes"
"context"
"crypto/sha256"
"errors"
@@ -12,6 +13,69 @@ import (
"github.com/rvbox/rvbox/internal/domain"
)
func TestExecutionSpecIsDurableAndQuotaCounted_HP_DISPATCH_07(t *testing.T) {
t.Parallel()
ctx := context.Background()
directory := filepath.Join(t.TempDir(), "spool")
store := openTestStore(t, ctx, directory, DefaultTombstoneLimit)
issue := testUUID(t, "019c46f1-1d02-7000-8000-000000000002")
spec := []byte("deterministic execution specification")
command := testCommand(issue, []byte("spec-hash"))
command.ExecutionSpec = spec
acceptedAt := time.Date(2026, time.September, 6, 12, 0, 0, 0, time.UTC)
accepted, err := store.AcceptCommand(ctx, command, acceptedAt)
if err != nil || accepted.Duplicate {
t.Fatalf("spec acceptance = %#v, %v", accepted, err)
}
if !bytes.Equal(accepted.Command.ExecutionSpec, spec) {
t.Fatalf("accepted spec = %q, want %q", accepted.Command.ExecutionSpec, spec)
}
var base, total, specCharge uint64
if err := store.db.QueryRowContext(ctx, `SELECT base_charged_bytes, total_charged_bytes FROM commands WHERE issue_uuid = ?`, issue[:]).Scan(&base, &total); err != nil {
t.Fatal(err)
}
if err := store.db.QueryRowContext(ctx, `SELECT charged_bytes FROM command_specs WHERE issue_uuid = ?`, issue[:]).Scan(&specCharge); err != nil {
t.Fatal(err)
}
if specCharge == 0 || total != base+specCharge {
t.Fatalf("spec charge accounting = base=%d total=%d spec=%d", base, total, specCharge)
}
duplicate, err := store.AcceptCommand(ctx, command, acceptedAt.Add(time.Second))
if err != nil || !duplicate.Duplicate || !bytes.Equal(duplicate.Command.ExecutionSpec, spec) {
t.Fatalf("spec replay = %#v, %v", duplicate, err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store = openTestStore(t, ctx, directory, DefaultTombstoneLimit)
reopened, err := store.AcceptCommand(ctx, command, acceptedAt.Add(2*time.Second))
if err != nil || !reopened.Duplicate || !bytes.Equal(reopened.Command.ExecutionSpec, spec) {
t.Fatalf("spec replay after reopen = %#v, %v", reopened, err)
}
report, err := store.Check(ctx)
if err != nil || report.SpecsChecked != 1 {
t.Fatalf("spec recovery report = %#v, %v", report, err)
}
}
func TestExecutionSpecCorruptionMarksSpoolDirty_BH_DISPATCH_07(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t, ctx, filepath.Join(t.TempDir(), "spool"), DefaultTombstoneLimit)
issue := testUUID(t, "019c46f1-1d02-7000-8000-000000000003")
command := testCommand(issue, []byte("spec-corrupt"))
command.ExecutionSpec = []byte("payload")
if _, err := store.AcceptCommand(ctx, command, time.Now().UTC()); err != nil {
t.Fatal(err)
}
if _, err := store.db.ExecContext(ctx, `UPDATE command_specs SET payload = ? WHERE issue_uuid = ?`, []byte("tampered"), issue[:]); err != nil {
t.Fatal(err)
}
if _, err := store.Check(ctx); !errors.Is(err, ErrStoredPayloadChecksum) {
t.Fatalf("corrupt execution spec Check error = %v, want ErrStoredPayloadChecksum", err)
}
}
func TestStoreDurableIdentityAndSingleInstance_HP_CLIENT_01(t *testing.T) {
t.Parallel()
ctx := context.Background()