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 {