feat: persist and dispatch client command input
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user