185 lines
9.0 KiB
Go
185 lines
9.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/binary"
|
|
"errors"
|
|
"time"
|
|
|
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
|
"github.com/rvbox/rvbox/internal/domain"
|
|
)
|
|
|
|
var ErrSignalDeliveryUnavailable = errors.New("signal delivery is unavailable for this command state")
|
|
|
|
type SignalInput struct {
|
|
IssueUUID domain.UUID
|
|
ClientID string
|
|
RequestUUID domain.UUID
|
|
Signal rvboxv1.SignalKind
|
|
Hash [32]byte
|
|
OccurredAt time.Time
|
|
}
|
|
|
|
type SignalResult struct {
|
|
CommandRevision uint64
|
|
Duplicate bool
|
|
Cancelled bool
|
|
Pending bool
|
|
}
|
|
|
|
type SignalIntent struct {
|
|
IssueUUID domain.UUID
|
|
CommandRevision uint64
|
|
Signal rvboxv1.SignalKind
|
|
}
|
|
|
|
// SignalCommand cancels queued work locally or records a revision-bound,
|
|
// replayable signal intent for dispatched/accepted/running work. The session
|
|
// layer later exposes that intent and the client acknowledges it as a durable
|
|
// command event.
|
|
func (store *Store) SignalCommand(ctx context.Context, input SignalInput) (SignalResult, error) {
|
|
if input.IssueUUID == (domain.UUID{}) || input.ClientID == "" || input.RequestUUID == (domain.UUID{}) || input.Signal < rvboxv1.SignalKind_SIGNAL_HUP || input.Signal > rvboxv1.SignalKind_SIGNAL_USR2 || input.Hash == [32]byte{} || input.OccurredAt.IsZero() {
|
|
return SignalResult{}, errors.New("invalid signal request")
|
|
}
|
|
store.writeMu.Lock()
|
|
defer store.writeMu.Unlock()
|
|
database, err := store.openDatabase()
|
|
if err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
method := "signal_command"
|
|
target := input.IssueUUID.String()
|
|
if existing, found, lookupErr := lookupControlMutation(ctx, database, input.RequestUUID, method, target, input.Hash); lookupErr != nil {
|
|
return SignalResult{}, lookupErr
|
|
} else if found {
|
|
if len(existing) != 8 {
|
|
return SignalResult{}, ErrInvalidSegmentRecord
|
|
}
|
|
return SignalResult{CommandRevision: binary.BigEndian.Uint64(existing), Duplicate: true}, nil
|
|
}
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
var lifecycle uint32
|
|
var revision uint64
|
|
var targetGeneration sql.NullInt64
|
|
err = tx.QueryRowContext(ctx, `SELECT lifecycle, revision, target_session_generation FROM commands WHERE issue_uuid = ? AND client_id = ?`, input.IssueUUID[:], input.ClientID).Scan(&lifecycle, &revision, &targetGeneration)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return SignalResult{}, ErrCommandNotFound
|
|
}
|
|
if err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
if lifecycle != uint32(rvboxv1.CommandLifecycle_COMMAND_QUEUED) && (lifecycle < uint32(rvboxv1.CommandLifecycle_COMMAND_DISPATCHED) || lifecycle > uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING) || !targetGeneration.Valid || targetGeneration.Int64 <= 0) {
|
|
return SignalResult{}, ErrSignalDeliveryUnavailable
|
|
}
|
|
if lifecycle != uint32(rvboxv1.CommandLifecycle_COMMAND_QUEUED) {
|
|
charge, chargeErr := EstimateCharge(ChargeInput{SQLiteRows: 1, IndexEntries: 1})
|
|
if chargeErr != nil {
|
|
return SignalResult{}, chargeErr
|
|
}
|
|
var commandCharged, closeout, clientCharged, serverCharged uint64
|
|
if err := tx.QueryRowContext(ctx, `SELECT commands.charged_bytes, commands.closeout_remaining_bytes, clients.charged_bytes, storage_counters.command_charged_bytes
|
|
FROM commands JOIN clients ON clients.client_id = commands.client_id JOIN storage_counters ON storage_counters.singleton = 1
|
|
WHERE commands.issue_uuid = ? AND commands.client_id = ?`, input.IssueUUID[:], input.ClientID).Scan(&commandCharged, &closeout, &clientCharged, &serverCharged); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
freeBytes, freeErr := store.freeSpaceProbe.AvailableBytes(store.dataDir)
|
|
if freeErr != nil {
|
|
return SignalResult{}, freeErr
|
|
}
|
|
reservation, reserveErr := CheckReservation(store.quotaLimits, ReservationState{CommandTotalCharged: commandCharged, CloseoutRemaining: closeout, ClientTotalCharged: clientCharged, ServerTotalCharged: serverCharged, FilesystemFreeBytes: freeBytes}, ReservationRequest{ChargedBytes: charge})
|
|
if reserveErr != nil {
|
|
return SignalResult{}, reserveErr
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO signal_intents (request_uuid, issue_uuid, signal, command_revision, acknowledged, created_at) VALUES (?, ?, ?, ?, 0, ?)`, input.RequestUUID[:], input.IssueUUID[:], input.Signal, revision, input.OccurredAt.UTC().UnixNano()); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE commands SET charged_bytes = ?, closeout_remaining_bytes = ? WHERE issue_uuid = ? AND charged_bytes = ?`, reservation.CommandTotalCharged, reservation.CloseoutRemaining, input.IssueUUID[:], commandCharged); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE clients SET charged_bytes = ? WHERE client_id = ? AND charged_bytes = ?`, reservation.ClientTotalCharged, input.ClientID, clientCharged); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE storage_counters SET command_charged_bytes = ? WHERE singleton = 1 AND command_charged_bytes = ?`, reservation.ServerTotalCharged, serverCharged); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
result := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(result, revision)
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO control_mutations (request_uuid, method, owner_kind, owner_id, target, immutable_sha256, assigned_revision, result, created_at) VALUES (?, ?, 'command', ?, ?, ?, ?, ?, ?)`, input.RequestUUID[:], method, input.IssueUUID.String(), target, input.Hash[:], revision, result, input.OccurredAt.UTC().UnixNano()); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
auditPayload := []byte{byte(input.Signal)}
|
|
auditHash := sha256.Sum256(auditPayload)
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO audit_events (occurred_at, source, action, outcome, compression, payload, raw_bytes, stored_bytes, sha256) VALUES (?, 'control', ?, 'success', 1, ?, ?, ?, ?)`, input.OccurredAt.UTC().UnixNano(), method, auditPayload, len(auditPayload), len(auditPayload), auditHash[:]); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
return SignalResult{CommandRevision: revision, Pending: true}, nil
|
|
}
|
|
if revision == ^uint64(0) {
|
|
return SignalResult{}, errors.New("command revision exhausted")
|
|
}
|
|
nextRevision := revision + 1
|
|
if _, err := tx.ExecContext(ctx, `UPDATE commands SET lifecycle = ?, terminal_time = ?, revision = ? WHERE issue_uuid = ? AND client_id = ? AND lifecycle = ? AND revision = ?`, rvboxv1.CommandLifecycle_COMMAND_CANCELLED, input.OccurredAt.UTC().UnixNano(), nextRevision, input.IssueUUID[:], input.ClientID, rvboxv1.CommandLifecycle_COMMAND_QUEUED, revision); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
result := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(result, nextRevision)
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO control_mutations (request_uuid, method, owner_kind, owner_id, target, immutable_sha256, assigned_revision, result, created_at) VALUES (?, ?, 'command', ?, ?, ?, ?, ?, ?)`, input.RequestUUID[:], method, input.IssueUUID.String(), target, input.Hash[:], nextRevision, result, input.OccurredAt.UTC().UnixNano())
|
|
if err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
// Keep a compact audit digest; command text/stdin is never copied to the
|
|
// audit payload.
|
|
auditPayload := []byte{byte(input.Signal)}
|
|
auditHash := sha256.Sum256(auditPayload)
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO audit_events (occurred_at, source, action, outcome, compression, payload, raw_bytes, stored_bytes, sha256) VALUES (?, 'control', ?, 'success', 1, ?, ?, ?, ?)`, input.OccurredAt.UTC().UnixNano(), method, auditPayload, len(auditPayload), len(auditPayload), auditHash[:])
|
|
if err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return SignalResult{}, err
|
|
}
|
|
return SignalResult{CommandRevision: nextRevision, Cancelled: true}, nil
|
|
}
|
|
|
|
func (store *Store) PendingSignals(ctx context.Context, clientID string, generation uint64) ([]SignalIntent, error) {
|
|
if clientID == "" || generation == 0 {
|
|
return nil, errors.New("client ID and generation are required")
|
|
}
|
|
database, err := store.openDatabase()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := database.QueryContext(ctx, `SELECT i.issue_uuid, i.command_revision, i.signal
|
|
FROM signal_intents i JOIN commands c ON c.issue_uuid = i.issue_uuid
|
|
WHERE c.client_id = ? AND c.target_session_generation = ? AND c.lifecycle BETWEEN 2 AND 4 AND i.acknowledged = 0
|
|
ORDER BY i.created_at, i.request_uuid`, clientID, generation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []SignalIntent
|
|
for rows.Next() {
|
|
var encoded []byte
|
|
var intent SignalIntent
|
|
if err := rows.Scan(&encoded, &intent.CommandRevision, &intent.Signal); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(encoded) != len(domain.UUID{}) || intent.CommandRevision == 0 || intent.Signal < rvboxv1.SignalKind_SIGNAL_HUP || intent.Signal > rvboxv1.SignalKind_SIGNAL_USR2 {
|
|
return nil, ErrInvalidSegmentRecord
|
|
}
|
|
copy(intent.IssueUUID[:], encoded)
|
|
result = append(result, intent)
|
|
}
|
|
return result, rows.Err()
|
|
}
|