feat: add durable client command spool
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package spool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type migration struct {
|
||||
version uint32
|
||||
sql string
|
||||
}
|
||||
|
||||
var migrations = []migration{{version: 1, sql: schemaV1}}
|
||||
|
||||
func applyMigrations(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY CHECK(version > 0), checksum TEXT NOT NULL, applied_at INTEGER NOT NULL
|
||||
) STRICT`); err != nil {
|
||||
return fmt.Errorf("create client spool migration table: %w", err)
|
||||
}
|
||||
for _, current := range migrations {
|
||||
checksumBytes := sha256.Sum256([]byte(current.sql))
|
||||
checksum := hex.EncodeToString(checksumBytes[:])
|
||||
var stored string
|
||||
err := db.QueryRowContext(ctx, `SELECT checksum FROM schema_migrations WHERE version = ?`, current.version).Scan(&stored)
|
||||
if err == nil {
|
||||
if stored != checksum {
|
||||
return fmt.Errorf("client spool migration %d checksum mismatch", current.version)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, current.sql); err == nil {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, checksum, applied_at) VALUES (?, ?, unixepoch())`, current.version, checksum)
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply client spool migration %d: %w", current.version, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit client spool migration %d: %w", current.version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const schemaV1 = `
|
||||
CREATE TABLE commands (
|
||||
issue_uuid BLOB PRIMARY KEY CHECK(length(issue_uuid) = 16),
|
||||
immutable_sha256 BLOB NOT NULL CHECK(length(immutable_sha256) = 32),
|
||||
command_revision INTEGER NOT NULL CHECK(command_revision > 0),
|
||||
phase INTEGER NOT NULL CHECK(phase BETWEEN 1 AND 11),
|
||||
terminal INTEGER NOT NULL DEFAULT 0 CHECK(terminal IN (0, 1)),
|
||||
next_local_ordinal INTEGER NOT NULL DEFAULT 1 CHECK(next_local_ordinal > 0),
|
||||
next_event_seq INTEGER NOT NULL DEFAULT 1 CHECK(next_event_seq > 0),
|
||||
last_server_ack INTEGER NOT NULL DEFAULT 0 CHECK(last_server_ack >= 0),
|
||||
accepted_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE events (
|
||||
issue_uuid BLOB NOT NULL REFERENCES commands(issue_uuid) ON DELETE CASCADE,
|
||||
local_ordinal INTEGER NOT NULL CHECK(local_ordinal > 0),
|
||||
event_seq INTEGER CHECK(event_seq IS NULL OR event_seq > 0),
|
||||
event_kind INTEGER NOT NULL CHECK(event_kind > 0),
|
||||
compression INTEGER NOT NULL CHECK(compression IN (1, 2)),
|
||||
raw_bytes INTEGER NOT NULL CHECK(raw_bytes >= 0),
|
||||
payload BLOB NOT NULL,
|
||||
payload_sha256 BLOB NOT NULL CHECK(length(payload_sha256) = 32),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(issue_uuid, local_ordinal),
|
||||
UNIQUE(issue_uuid, event_seq)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
CREATE INDEX events_send_window ON events(issue_uuid, event_seq, local_ordinal);
|
||||
CREATE TABLE command_tombstones (
|
||||
issue_uuid BLOB PRIMARY KEY CHECK(length(issue_uuid) = 16),
|
||||
immutable_sha256 BLOB NOT NULL CHECK(length(immutable_sha256) = 32),
|
||||
acknowledged_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX tombstones_fifo ON command_tombstones(acknowledged_at, issue_uuid);
|
||||
`
|
||||
Reference in New Issue
Block a user