feat: establish Orbit models, migrations and development baseline
This commit is contained in:
46
internal/config/config.go
Normal file
46
internal/config/config.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
MySQLDSN string
|
||||
MaxOpen int
|
||||
MaxIdle int
|
||||
}
|
||||
|
||||
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
||||
func FromEnv(get func(string) string) (Config, error) {
|
||||
c := Config{HTTPAddr: get("ORBIT_HTTP_ADDR"), MySQLDSN: get("ORBIT_MYSQL_DSN"), MaxOpen: 20, MaxIdle: 5}
|
||||
if c.HTTPAddr == "" {
|
||||
c.HTTPAddr = "127.0.0.1:8080"
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(c.HTTPAddr); err != nil {
|
||||
return Config{}, errors.New("invalid ORBIT_HTTP_ADDR")
|
||||
}
|
||||
if c.MySQLDSN == "" {
|
||||
return Config{}, errors.New("ORBIT_MYSQL_DSN is required")
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
dst *int
|
||||
}{{"ORBIT_DB_MAX_OPEN", &c.MaxOpen}, {"ORBIT_DB_MAX_IDLE", &c.MaxIdle}} {
|
||||
if raw := get(field.name); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
return Config{}, fmt.Errorf("%s must be a nonnegative integer", field.name)
|
||||
}
|
||||
*field.dst = n
|
||||
}
|
||||
}
|
||||
if c.MaxOpen <= 0 || c.MaxIdle > c.MaxOpen {
|
||||
return Config{}, errors.New("database pool requires 0 <= max_idle <= max_open and max_open > 0")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
66
internal/storage/mysql/db.go
Normal file
66
internal/storage/mysql/db.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Package mysql owns SQL connections, explicit migrations and transaction boundaries.
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// Open configures UTC, bounded I/O and an explicit connection pool. It never
|
||||
// applies migrations and never logs the DSN. TLS is chosen in the secret DSN.
|
||||
func Open(ctx context.Context, dsn string, maxOpen, maxIdle int) (*sql.DB, error) {
|
||||
if maxOpen <= 0 || maxIdle < 0 || maxIdle > maxOpen {
|
||||
return nil, errors.New("invalid database pool configuration")
|
||||
}
|
||||
cfg, err := driver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid MySQL DSN")
|
||||
}
|
||||
if cfg.DBName == "" {
|
||||
return nil, errors.New("MySQL database name required")
|
||||
}
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.UTC
|
||||
cfg.MultiStatements = false
|
||||
cfg.InterpolateParams = false
|
||||
cfg.Timeout = 5 * time.Second
|
||||
cfg.ReadTimeout = 10 * time.Second
|
||||
cfg.WriteTimeout = 10 * time.Second
|
||||
if cfg.Params == nil {
|
||||
cfg.Params = map[string]string{}
|
||||
}
|
||||
cfg.Params["time_zone"] = "'+00:00'"
|
||||
cfg.Params["sql_mode"] = "'STRICT_ALL_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
|
||||
connector, err := driver.NewConnector(cfg)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid MySQL connection configuration")
|
||||
}
|
||||
db := sql.OpenDB(connector)
|
||||
db.SetMaxOpenConns(maxOpen)
|
||||
db.SetMaxIdleConns(maxIdle)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
db.SetConnMaxIdleTime(time.Minute)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, errors.New("MySQL connection failed; check endpoint, credentials and TLS configuration")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// WithTx does not replay callbacks: a failed commit may have succeeded remotely.
|
||||
// Callers reconcile by stable operation identity before retrying an ambiguous write.
|
||||
func WithTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
253
internal/storage/mysql/integration_test.go
Normal file
253
internal/storage/mysql/integration_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestIntegrationSchemaAndConstraints(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 6, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var version string
|
||||
if err := db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("MySQL", version)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); results <- Migrate(ctx, db) }()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
id := func() core.ID {
|
||||
t.Helper()
|
||||
v, err := core.NewID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
reject := func(code uint16, q string, args ...any) {
|
||||
t.Helper()
|
||||
_, err := db.ExecContext(ctx, q, args...)
|
||||
var me *mysqldriver.MySQLError
|
||||
if !errors.As(err, &me) || me.Number != code {
|
||||
t.Fatalf("wanted MySQL %d, got %v", code, err)
|
||||
}
|
||||
}
|
||||
app, nsA, nsB, queueA := id(), id(), id(), id()
|
||||
exec("INSERT INTO orbit_applications(id,name) VALUES (?,?)", app, string(app))
|
||||
for _, ns := range []core.ID{nsA, nsB} {
|
||||
exec("INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,'test')", ns, app, string(ns))
|
||||
}
|
||||
exec("INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", nsA, queueA, string(queueA))
|
||||
taskSQL := `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,available_at,deadline_at,attempt_timeout_ms,max_attempts)
|
||||
VALUES (?,?,?,'object.prepare','1','1','{}','{}',UTC_TIMESTAMP(6),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR),5000,3)`
|
||||
taskA, taskB := id(), id()
|
||||
exec(taskSQL, nsA, taskA, queueA)
|
||||
exec(taskSQL, nsA, taskB, queueA)
|
||||
t.Run("namespace_isolation_and_safe_task_states", func(t *testing.T) {
|
||||
reject(1452, taskSQL, nsB, id(), queueA)
|
||||
reject(3819, "UPDATE pulse_tasks SET state='RUNNING' WHERE namespace_id=? AND id=?", nsA, taskA)
|
||||
reject(3819, "UPDATE pulse_tasks SET state='UNRECOGNIZED' WHERE namespace_id=? AND id=?", nsA, taskA)
|
||||
reject(3819, "UPDATE pulse_tasks SET state='SUCCEEDED' WHERE namespace_id=? AND id=?", nsA, taskA)
|
||||
reject(3819, "UPDATE pulse_tasks SET attempt_count=4 WHERE namespace_id=? AND id=?", nsA, taskA)
|
||||
var scanned core.ID
|
||||
var available time.Time
|
||||
if err := db.QueryRowContext(ctx, "SELECT id,available_at FROM pulse_tasks WHERE namespace_id=? AND id=?", nsA, taskA).Scan(&scanned, &available); err != nil || scanned != taskA || available.Location() != time.UTC {
|
||||
t.Fatalf("binary/time roundtrip: %s %v %v", scanned, available, err)
|
||||
}
|
||||
})
|
||||
t.Run("transaction_rollback", func(t *testing.T) {
|
||||
sentinel := errors.New("abort workflow update")
|
||||
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE pulse_queues SET paused=TRUE WHERE namespace_id=? AND id=?", nsA, queueA); err != nil {
|
||||
return err
|
||||
}
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var paused bool
|
||||
if err := db.QueryRowContext(ctx, "SELECT paused FROM pulse_queues WHERE namespace_id=? AND id=?", nsA, queueA).Scan(&paused); err != nil || paused {
|
||||
t.Fatalf("rollback failed: %v %v", paused, err)
|
||||
}
|
||||
})
|
||||
hash := core.Digest(sha256.Sum256([]byte("test-operation")))
|
||||
t.Run("idempotency_scope", func(t *testing.T) {
|
||||
q := `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at) VALUES (?,? ,?,'',?,?,'task',?,'{}',DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 DAY))`
|
||||
exec(q, nsA, id(), "task.submit", hash, hash, taskA)
|
||||
reject(1062, q, nsA, id(), "task.submit", hash, hash, taskB)
|
||||
exec(q, nsB, id(), "task.submit", hash, hash, taskB)
|
||||
exec(q, nsA, id(), "workflow.start", hash, hash, taskB)
|
||||
})
|
||||
definition, defVersion, gate, workflow, step, activation := id(), id(), id(), id(), id(), id()
|
||||
exec("INSERT INTO weave_definitions(namespace_id,id,name) VALUES (?,?,?)", nsA, definition, string(definition))
|
||||
versionSQL := "INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by) VALUES (?,?,?,'v1',1,'{}',?,'test')"
|
||||
exec(versionSQL, nsA, defVersion, definition, hash)
|
||||
exec("INSERT INTO pulse_execution_gates(namespace_id,id) VALUES (?,?)", nsA, gate)
|
||||
exec(`INSERT INTO weave_instances(namespace_id,id,definition_id,definition_version_id,gate_id,input,policy_snapshot,deadline_at) VALUES (?,?,?,?,?,'{}','{}',DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR))`, nsA, workflow, definition, defVersion, gate)
|
||||
exec("INSERT INTO weave_steps(namespace_id,id,workflow_id,`key`,kind) VALUES (?,?,?,'poll','POLL')", nsA, step, workflow)
|
||||
exec("INSERT INTO weave_activations(namespace_id,id,workflow_id,step_id,sequence,input) VALUES (?,?,?,?,1,'{}')", nsA, activation, workflow, step)
|
||||
exec("UPDATE weave_steps SET current_activation_id=? WHERE namespace_id=? AND id=?", activation, nsA, step)
|
||||
t.Run("published_definitions_are_immutable", func(t *testing.T) {
|
||||
reject(1062, versionSQL, nsA, id(), definition, hash)
|
||||
reject(1644, "UPDATE weave_definition_versions SET specification='{}' WHERE namespace_id=? AND id=?", nsA, defVersion)
|
||||
})
|
||||
t.Run("poll_identity_and_budget", func(t *testing.T) {
|
||||
poll := id()
|
||||
exec("INSERT INTO weave_poll_states(namespace_id,id,workflow_id,activation_id,max_rounds,max_attempts_per_round,interval_ms,deadline_at) VALUES (?,?,?,?,2,3,1000,DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR))", nsA, poll, workflow, activation)
|
||||
q := "INSERT INTO weave_poll_rounds(namespace_id,id,workflow_id,activation_id,round,task_id) VALUES (?,?,?,?,?,?)"
|
||||
exec(q, nsA, id(), workflow, activation, 1, taskA)
|
||||
reject(1062, q, nsA, id(), workflow, activation, 1, taskB)
|
||||
reject(1062, q, nsA, id(), workflow, activation, 2, taskA)
|
||||
reject(3819, "UPDATE weave_poll_states SET used_rounds=3 WHERE namespace_id=? AND id=?", nsA, poll)
|
||||
})
|
||||
t.Run("events_and_audit_are_append_only", func(t *testing.T) {
|
||||
event, audit := id(), id()
|
||||
q := "INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,'task',?,1,'task.accepted','{}',UTC_TIMESTAMP(6))"
|
||||
exec(q, nsA, event, taskA)
|
||||
reject(1062, q, nsA, id(), taskA)
|
||||
reject(1644, "UPDATE orbit_events SET payload='{}' WHERE namespace_id=? AND id=?", nsA, event)
|
||||
exec("INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason) VALUES (?,?,?,'test','pause','workflow',?,'integration test')", nsA, audit, id(), workflow)
|
||||
reject(1644, "UPDATE orbit_audit_events SET reason='rewritten' WHERE namespace_id=? AND id=?", nsA, audit)
|
||||
})
|
||||
t.Run("model_columns_match_migrated_database", func(t *testing.T) { checkModelColumns(t, ctx, db) })
|
||||
t.Run("dirty_or_modified_migration_refuses_startup", func(t *testing.T) {
|
||||
exec("UPDATE orbit_schema_migrations SET dirty=TRUE WHERE version=1")
|
||||
if err := Migrate(ctx, db); err == nil {
|
||||
t.Fatal("continued dirty migration")
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err == nil {
|
||||
t.Fatal("accepted dirty schema")
|
||||
}
|
||||
exec("UPDATE orbit_schema_migrations SET dirty=FALSE,checksum=REPEAT('0',64) WHERE version=1")
|
||||
if err := Migrate(ctx, db); err == nil {
|
||||
t.Fatal("accepted modified migration")
|
||||
}
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec("UPDATE orbit_schema_migrations SET checksum=? WHERE version=1", migrations[0].Checksum)
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type tableModel interface{ TableName() string }
|
||||
|
||||
func checkModelColumns(t *testing.T, ctx context.Context, db *sql.DB) {
|
||||
t.Helper()
|
||||
models := []tableModel{
|
||||
platform.Application{},
|
||||
platform.Namespace{},
|
||||
platform.Credential{},
|
||||
platform.IdempotencyKey{},
|
||||
platform.Event{},
|
||||
platform.Subscription{},
|
||||
platform.Delivery{},
|
||||
platform.AuditEvent{},
|
||||
pulse.Queue{},
|
||||
pulse.ExecutionGate{},
|
||||
pulse.WorkerSession{},
|
||||
pulse.Task{},
|
||||
pulse.Attempt{},
|
||||
pulse.AttemptReceipt{},
|
||||
pulse.ConcurrencyLimit{},
|
||||
pulse.Permit{},
|
||||
pulse.Schedule{},
|
||||
pulse.ScheduleVersion{},
|
||||
pulse.Occurrence{},
|
||||
weave.Definition{},
|
||||
weave.DefinitionVersion{},
|
||||
weave.Instance{},
|
||||
weave.Step{},
|
||||
weave.Activation{},
|
||||
weave.Wait{},
|
||||
weave.Signal{},
|
||||
weave.PollState{},
|
||||
weave.PollRound{},
|
||||
weave.InboxEvent{},
|
||||
weave.PendingAdvance{},
|
||||
}
|
||||
for _, m := range models {
|
||||
fields := map[string]bool{}
|
||||
var visit func(reflect.Type)
|
||||
visit = func(typ reflect.Type) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
f := typ.Field(i)
|
||||
if f.Anonymous {
|
||||
visit(f.Type)
|
||||
} else if col := f.Tag.Get("db"); col != "" {
|
||||
fields[col] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(reflect.TypeOf(m))
|
||||
rows, err := db.QueryContext(ctx, "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", m.TableName())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var column string
|
||||
if err := rows.Scan(&column); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count++
|
||||
if !fields[column] {
|
||||
t.Errorf("%s.%s has no Go field", m.TableName(), column)
|
||||
}
|
||||
delete(fields, column)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count == 0 || len(fields) > 0 {
|
||||
t.Errorf("%s missing columns: %v", m.TableName(), fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
207
internal/storage/mysql/migrate.go
Normal file
207
internal/storage/mysql/migrate.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.up.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
type Migration struct {
|
||||
Version uint64
|
||||
Name string
|
||||
Checksum string
|
||||
SQL string
|
||||
}
|
||||
type MigrationStatus struct {
|
||||
Version uint64 `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Checksum string `json:"checksum"`
|
||||
Dirty bool `json:"dirty"`
|
||||
}
|
||||
|
||||
func Migrations() ([]Migration, error) {
|
||||
names, err := fs.Glob(migrationFiles, "migrations/*.up.sql")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]Migration, 0, len(names))
|
||||
seen := map[uint64]bool{}
|
||||
for _, name := range names {
|
||||
base := strings.TrimPrefix(name, "migrations/")
|
||||
version, err := strconv.ParseUint(strings.SplitN(base, "_", 2)[0], 10, 64)
|
||||
if err != nil || version == 0 || seen[version] {
|
||||
return nil, fmt.Errorf("invalid or duplicate migration version: %s", base)
|
||||
}
|
||||
seen[version] = true
|
||||
b, err := migrationFiles.ReadFile(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256(b)
|
||||
result = append(result, Migration{version, base, hex.EncodeToString(digest[:]), string(b)})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Version < result[j].Version })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Migrate applies explicit forward migrations while holding one connection-scoped
|
||||
// advisory lock. MySQL DDL commits implicitly: interruption leaves a dirty record
|
||||
// and stops further migrations until an operator reconciles the partial schema.
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
var dbName, version string
|
||||
if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), VERSION()").Scan(&dbName, &version); err != nil {
|
||||
return err
|
||||
}
|
||||
var major, minor int
|
||||
if _, err := fmt.Sscanf(version, "%d.%d", &major, &minor); err != nil || strings.Contains(strings.ToLower(version), "mariadb") || major < 8 || major == 8 && minor < 4 {
|
||||
return errors.New("migrations require MySQL 8.4 or newer")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(dbName))
|
||||
lockName := "orbit_migrate_" + hex.EncodeToString(digest[:20])
|
||||
var acquired sql.NullInt64
|
||||
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 10)", lockName).Scan(&acquired); err != nil {
|
||||
return err
|
||||
}
|
||||
if !acquired.Valid || acquired.Int64 != 1 {
|
||||
return errors.New("migration lock unavailable")
|
||||
}
|
||||
defer func() {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
var released sql.NullInt64
|
||||
if err := conn.QueryRowContext(cleanup, "SELECT RELEASE_LOCK(?)", lockName).Scan(&released); err != nil || !released.Valid || released.Int64 != 1 {
|
||||
_ = conn.Raw(func(any) error { return driver.ErrBadConn })
|
||||
}
|
||||
}()
|
||||
if _, err := conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS orbit_schema_migrations (
|
||||
version BIGINT UNSIGNED PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
dirty BOOLEAN NOT NULL,
|
||||
applied_at DATETIME(6) NULL
|
||||
) ENGINE=InnoDB`); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := conn.QueryContext(ctx, "SELECT version, name, checksum, dirty FROM orbit_schema_migrations ORDER BY version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applied := map[uint64]MigrationStatus{}
|
||||
for rows.Next() {
|
||||
var m MigrationStatus
|
||||
if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
applied[m.Version] = m
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
known := map[uint64]Migration{}
|
||||
for _, m := range migrations {
|
||||
known[m.Version] = m
|
||||
}
|
||||
for v, a := range applied {
|
||||
m, ok := known[v]
|
||||
if !ok {
|
||||
return fmt.Errorf("database migration %d is newer than this binary", v)
|
||||
}
|
||||
if a.Dirty {
|
||||
return fmt.Errorf("migration %d is dirty; reconcile partial DDL before proceeding", v)
|
||||
}
|
||||
if a.Checksum != m.Checksum || a.Name != m.Name {
|
||||
return fmt.Errorf("migration %d checksum/name mismatch", v)
|
||||
}
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if _, ok := applied[m.Version]; ok {
|
||||
continue
|
||||
}
|
||||
for v := range applied {
|
||||
if v > m.Version {
|
||||
return errors.New("out-of-order migration requires operator review")
|
||||
}
|
||||
}
|
||||
statements, err := splitSQL(m.SQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %d: %w", m.Version, err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, statement := range statements {
|
||||
if _, err := conn.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("migration %d statement %d failed (marked dirty): %w", m.Version, i+1, err)
|
||||
}
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "UPDATE orbit_schema_migrations SET dirty=FALSE, applied_at=UTC_TIMESTAMP(6) WHERE version=? AND checksum=?", m.Version, m.Checksum); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Status(ctx context.Context, db *sql.DB) ([]MigrationStatus, error) {
|
||||
rows, err := db.QueryContext(ctx, "SELECT version,name,checksum,dirty FROM orbit_schema_migrations ORDER BY version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []MigrationStatus{}
|
||||
for rows.Next() {
|
||||
var m MigrationStatus
|
||||
if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CheckSchema rejects missing, modified, dirty and unexpected versions. It does
|
||||
// not execute DDL. A matched migration ledger is not proof against manual DDL drift.
|
||||
func CheckSchema(ctx context.Context, db *sql.DB) error {
|
||||
wanted, err := Migrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actual, err := Status(ctx, db)
|
||||
if err != nil {
|
||||
return errors.New("schema unavailable; run orbit-migrate up with migration credentials")
|
||||
}
|
||||
if len(actual) != len(wanted) {
|
||||
return errors.New("schema version mismatch")
|
||||
}
|
||||
for i, m := range wanted {
|
||||
a := actual[i]
|
||||
if a.Version != m.Version || a.Name != m.Name || a.Checksum != m.Checksum || a.Dirty {
|
||||
return errors.New("schema is dirty or does not match this binary")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
816
internal/storage/mysql/migrations/000001_initial.up.sql
Normal file
816
internal/storage/mysql/migrations/000001_initial.up.sql
Normal file
@@ -0,0 +1,816 @@
|
||||
-- Orbit initial schema, MySQL 8.4+. All times are UTC at microsecond precision.
|
||||
-- Do not edit an applied migration. Add a new forward migration instead.
|
||||
|
||||
CREATE TABLE orbit_applications (
|
||||
id BINARY(16) NOT NULL COMMENT '应用标识,全局唯一',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用;关闭后拒绝相应的新接入或新操作',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_application_name (name),
|
||||
CONSTRAINT ck_orbit_applications_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='接入应用,承载通用应用身份与启停配置';
|
||||
|
||||
CREATE TABLE orbit_namespaces (
|
||||
id BINARY(16) NOT NULL COMMENT '命名空间标识,全局唯一',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`application_id` BINARY(16) NOT NULL COMMENT '所属应用标识',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`environment` VARCHAR(32) NOT NULL COMMENT '环境标识,用于区分开发、测试和生产等隔离范围',
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用;关闭后拒绝相应的新接入或新操作',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_namespace_name (application_id, environment, name),
|
||||
CONSTRAINT ck_orbit_namespaces_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='应用在指定环境中的资源隔离边界';
|
||||
|
||||
CREATE TABLE orbit_credentials (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`secret_digest` BINARY(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
|
||||
`permissions` JSON NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
|
||||
`expires_at` DATETIME(6) NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间',
|
||||
`revoked_at` DATETIME(6) NULL COMMENT '撤销时间;为空表示尚未撤销',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_credential_name (namespace_id, name),
|
||||
CONSTRAINT ck_orbit_credentials_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
|
||||
|
||||
CREATE TABLE orbit_idempotency_keys (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`operation` VARCHAR(64) NOT NULL COMMENT '稳定操作类型,用于区分不同接口的幂等作用域',
|
||||
`scope_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '资源作用域标识;空字符串表示当前操作的默认作用域',
|
||||
`key_digest` BINARY(32) NOT NULL COMMENT '稳定幂等键或资源键的 SHA-256 摘要',
|
||||
`request_digest` BINARY(32) NOT NULL COMMENT '规范化请求语义的 SHA-256 摘要,用于识别同键不同内容',
|
||||
`resource_kind` VARCHAR(32) NOT NULL COMMENT '关联资源类型;多态关联需要服务层额外校验',
|
||||
`resource_id` BINARY(16) NOT NULL COMMENT '关联资源标识,不能单独作为访问授权依据',
|
||||
`response` JSON NOT NULL COMMENT '首次可靠提交后的响应 JSON,供重复请求补取确认',
|
||||
`expires_at` DATETIME(6) NOT NULL COMMENT '幂等保留截止时间,不应短于约定的重发与恢复窗口',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_idempotency_scope (namespace_id, operation, scope_key, key_digest),
|
||||
KEY ix_idempotency_expiry (expires_at, namespace_id, id),
|
||||
CONSTRAINT ck_orbit_idempotency_keys_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='请求幂等记录,关联语义摘要与首次受理响应';
|
||||
|
||||
CREATE TABLE orbit_events (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`aggregate_kind` VARCHAR(32) NOT NULL COMMENT '产生事件的聚合类型,例如任务或流程',
|
||||
`aggregate_id` BINARY(16) NOT NULL COMMENT '产生事件的聚合标识',
|
||||
`aggregate_revision` BIGINT UNSIGNED NOT NULL COMMENT '来源聚合的状态版本,用于去重与迟到事件判断',
|
||||
`type` VARCHAR(64) NOT NULL COMMENT '事件或信号的稳定类型标识',
|
||||
`payload` JSON NOT NULL COMMENT '版本化输入或事件内容;仅承载通用 JSON,不解释行业规则',
|
||||
`occurred_at` DATETIME(6) NOT NULL COMMENT '事件发生时间,统一 UTC',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_event_revision (namespace_id, aggregate_kind, aggregate_id, aggregate_revision, type),
|
||||
CONSTRAINT ck_orbit_events_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_orbit_events_1 CHECK (aggregate_revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='追加保存的状态事件,供外部订阅或内部流程接续';
|
||||
|
||||
CREATE TABLE orbit_subscriptions (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`credential_id` BINARY(16) NOT NULL COMMENT '关联的命名空间凭据标识',
|
||||
`filter` JSON NOT NULL COMMENT '订阅过滤规则的 JSON 文档',
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用;关闭后拒绝相应的新接入或新操作',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_subscription_name (namespace_id, name),
|
||||
CONSTRAINT ck_orbit_subscriptions_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='事件订阅配置,绑定授权凭据及过滤规则';
|
||||
|
||||
CREATE TABLE orbit_deliveries (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`subscription_id` BINARY(16) NOT NULL COMMENT '目标订阅标识',
|
||||
`event_id` BINARY(16) NOT NULL COMMENT '来源持久化事件标识,用于消费去重',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验',
|
||||
`available_at` DATETIME(6) NOT NULL COMMENT '该事件最早允许再次交付的时间,统一 UTC',
|
||||
`lease_owner` BINARY(16) NULL COMMENT '租约持有者标识;未持有租约时为空',
|
||||
`lease_epoch` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '递增的执行或消费代次,用于隔离旧授权',
|
||||
`lease_until` DATETIME(6) NULL COMMENT '交付租约截止时间;没有交付租约时为空',
|
||||
`attempts` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已产生的交付尝试次数,不等同于业务执行次数',
|
||||
`acknowledged_at` DATETIME(6) NULL COMMENT '接收方可靠处理后的确认时间;未确认时为空',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_delivery_event (namespace_id, subscription_id, event_id),
|
||||
KEY ix_delivery_due (namespace_id, state, available_at, id),
|
||||
CONSTRAINT ck_orbit_deliveries_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_orbit_deliveries_1 CHECK (state IN ('PENDING', 'LEASED', 'ACKED', 'EXPIRED')),
|
||||
CONSTRAINT ck_orbit_deliveries_2 CHECK ((state = 'LEASED' AND lease_owner IS NOT NULL AND lease_until IS NOT NULL) OR (state <> 'LEASED' AND lease_owner IS NULL AND lease_until IS NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='单个事件向一个订阅的可靠交付记录';
|
||||
|
||||
CREATE TABLE orbit_audit_events (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`operation_id` BINARY(16) NOT NULL COMMENT '管理操作的稳定身份,重复操作必须保留同一身份',
|
||||
`actor_id` VARCHAR(128) NOT NULL COMMENT '经认证的操作主体标识,不能直接信任请求自报值',
|
||||
`action` VARCHAR(64) NOT NULL COMMENT '执行的管理动作,例如暂停、取消或核查',
|
||||
`resource_kind` VARCHAR(32) NOT NULL COMMENT '关联资源类型;多态关联需要服务层额外校验',
|
||||
`resource_id` BINARY(16) NOT NULL COMMENT '关联资源标识,不能单独作为访问授权依据',
|
||||
`reason` VARCHAR(1024) NOT NULL COMMENT '操作原因或处理说明,需要保留审计信息',
|
||||
`before` JSON NULL COMMENT '操作前的受控摘要 JSON,须脱敏',
|
||||
`after` JSON NULL COMMENT '操作后的受控摘要 JSON,须脱敏',
|
||||
`evidence_ref` VARCHAR(512) NULL COMMENT '业务核查证据的引用;为空表示没有附加引用',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_audit_operation (namespace_id, operation_id),
|
||||
KEY ix_audit_resource (namespace_id, resource_kind, resource_id, created_at, id),
|
||||
CONSTRAINT ck_orbit_audit_events_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='不可改写的管理审计,保留操作身份、原因和变更摘要';
|
||||
|
||||
CREATE TABLE pulse_queues (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止',
|
||||
`max_concurrency` INT UNSIGNED NOT NULL COMMENT '允许同时持有的有效执行许可上限,必须大于零',
|
||||
`rate_per_second` INT UNSIGNED NOT NULL COMMENT '每秒新执行的速率配置;实际限流由共享协调逻辑落实',
|
||||
`policy` JSON NOT NULL COMMENT '队列资源与执行配置的 JSON 文档',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_queue_name (namespace_id, name),
|
||||
CONSTRAINT ck_pulse_queues_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_queues_1 CHECK (max_concurrency > 0),
|
||||
CONSTRAINT ck_pulse_queues_2 CHECK (rate_per_second > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='任务路由与资源控制队列,不要求每个租户单独创建队列';
|
||||
|
||||
CREATE TABLE pulse_execution_gates (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止',
|
||||
`control_revision` BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '暂停、继续等控制操作的版本,避免旧操作覆盖新决定',
|
||||
`reason` VARCHAR(1024) NULL COMMENT '操作原因或处理说明,需要保留审计信息',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
CONSTRAINT ck_pulse_execution_gates_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_execution_gates_1 CHECK (control_revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='通用执行门控,协调暂停与新执行授权的竞争';
|
||||
|
||||
CREATE TABLE pulse_worker_sessions (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`worker_instance_id` BINARY(16) NOT NULL COMMENT 'Worker 进程实例标识,重连保留,进程重启应更换',
|
||||
`credential_id` BINARY(16) NOT NULL COMMENT '关联的命名空间凭据标识',
|
||||
`node_id` BINARY(16) NOT NULL COMMENT '承载当前连接的平台节点标识',
|
||||
`session_epoch` BIGINT UNSIGNED NOT NULL COMMENT '该 Worker 实例的连接代次,用于隔离旧会话',
|
||||
`capabilities` JSON NOT NULL COMMENT '已登记的处理器版本、队列等能力快照',
|
||||
`capacity` INT UNSIGNED NOT NULL COMMENT 'Worker 声明的执行容量,仍受平台权限和配额约束',
|
||||
`capacity_sequence` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '会话内容量消息序号,重复上报不得重复增加额度',
|
||||
`draining` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否正在排空连接;排空时不接收新任务',
|
||||
`expires_at` DATETIME(6) NOT NULL COMMENT '过期时间,统一 UTC;到期后的行为由对应资源策略决定',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_worker_epoch (namespace_id, worker_instance_id, session_epoch),
|
||||
KEY ix_worker_expiry (expires_at, namespace_id, id),
|
||||
CONSTRAINT ck_pulse_worker_sessions_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_worker_sessions_1 CHECK (session_epoch > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次 Worker 连接的持久化身份与能力快照';
|
||||
|
||||
CREATE TABLE pulse_tasks (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`queue_id` BINARY(16) NOT NULL COMMENT '任务所属队列,必须属于同一命名空间',
|
||||
`gate_id` BINARY(16) NULL COMMENT '可选执行门控;为空表示没有流程级门控,仍受队列与权限控制',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'WAITING' COMMENT '任务状态;与执行尝试状态分开,未知业务效果进入待核查',
|
||||
`handler` VARCHAR(128) NOT NULL COMMENT '业务 Worker 注册的处理器名称',
|
||||
`handler_version` VARCHAR(64) NOT NULL COMMENT '处理器兼容版本,受理后不得静默替换',
|
||||
`payload_version` VARCHAR(32) NOT NULL COMMENT '输入参数的解释版本',
|
||||
`payload` JSON NOT NULL COMMENT '版本化输入或事件内容;仅承载通用 JSON,不解释行业规则',
|
||||
`result` JSON NULL COMMENT '已保存的结果或结果引用 JSON;未取得结果时为空',
|
||||
`policy_snapshot` JSON NOT NULL COMMENT '受理时冻结的实际生效策略 JSON',
|
||||
`recovery_safety` VARCHAR(32) NOT NULL DEFAULT 'RECONCILE_ONLY' COMMENT '恢复安全等级;未知值按需要核查处理,不能默认自动重跑',
|
||||
`business_key` VARCHAR(191) NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等',
|
||||
`owner_kind` VARCHAR(32) NULL COMMENT '来源资源类型,例如流程激活;为空表示无来源关联',
|
||||
`owner_id` BINARY(16) NULL COMMENT '来源资源标识,必须与来源类型同时有值或同时为空',
|
||||
`priority` SMALLINT NOT NULL DEFAULT 0 COMMENT '同一调度范围内的优先级,不承诺跨队列全局顺序',
|
||||
`available_at` DATETIME(6) NOT NULL COMMENT '最早可被领取或处理的时间,统一 UTC',
|
||||
`start_deadline_at` DATETIME(6) NULL COMMENT '最晚允许新尝试开始的时间;为空时仅受总时限约束',
|
||||
`deadline_at` DATETIME(6) NOT NULL COMMENT '总截止时间,统一 UTC;到期不能证明外部业务没有生效',
|
||||
`attempt_timeout_ms` BIGINT UNSIGNED NOT NULL COMMENT '单次尝试的超时时长,单位毫秒',
|
||||
`max_attempts` INT UNSIGNED NOT NULL COMMENT '最多执行尝试数,包含首次尝试',
|
||||
`attempt_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已产生的执行尝试数,恢复时不得重置',
|
||||
`current_attempt_id` BINARY(16) NULL COMMENT '当前关联的执行尝试,必须属于本任务',
|
||||
`lease_epoch` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '递增的执行或消费代次,用于隔离旧授权',
|
||||
`lease_until` DATETIME(6) NULL COMMENT '当前执行授权截止时间;没有有效授权时为空',
|
||||
`cancel_requested_at` DATETIME(6) NULL COMMENT '取消意图的持久化时间;不等于取消已完成',
|
||||
`retry_of` BINARY(16) NULL COMMENT '来源记录标识,人工重新发起时用于保留历史关联',
|
||||
`completed_at` DATETIME(6) NULL COMMENT '终结时间;非终态为空,进入终态时必须写入',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
KEY ix_task_dispatch (namespace_id, queue_id, state, available_at, priority, id),
|
||||
KEY ix_task_lease_recovery (state, lease_until, namespace_id, id),
|
||||
KEY ix_task_business (namespace_id, business_key, created_at, id),
|
||||
CONSTRAINT ck_pulse_tasks_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_tasks_1 CHECK (state IN ('WAITING', 'LEASED', 'RUNNING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED')),
|
||||
CONSTRAINT ck_pulse_tasks_2 CHECK (recovery_safety IN ('IDEMPOTENT', 'RECONCILE_ONLY')),
|
||||
CONSTRAINT ck_pulse_tasks_3 CHECK (max_attempts > 0 AND attempt_count <= max_attempts),
|
||||
CONSTRAINT ck_pulse_tasks_4 CHECK (attempt_timeout_ms > 0),
|
||||
CONSTRAINT ck_pulse_tasks_5 CHECK (deadline_at > available_at),
|
||||
CONSTRAINT ck_pulse_tasks_6 CHECK (start_deadline_at IS NULL OR start_deadline_at <= deadline_at),
|
||||
CONSTRAINT ck_pulse_tasks_7 CHECK (((state IN ('LEASED', 'RUNNING')) AND current_attempt_id IS NOT NULL AND lease_until IS NOT NULL) OR ((state NOT IN ('LEASED', 'RUNNING')) AND lease_until IS NULL)),
|
||||
CONSTRAINT ck_pulse_tasks_8 CHECK ((state IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NOT NULL) OR (state NOT IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NULL)),
|
||||
CONSTRAINT ck_pulse_tasks_9 CHECK ((owner_kind IS NULL) = (owner_id IS NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='任务主记录,执行尝试另存历史,终态不自动回退';
|
||||
|
||||
CREATE TABLE pulse_attempts (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`task_id` BINARY(16) NOT NULL COMMENT '关联的 Pulse 任务标识',
|
||||
`epoch` BIGINT UNSIGNED NOT NULL COMMENT '该任务的授权代次,同一任务内唯一且递增',
|
||||
`session_id` BINARY(16) NOT NULL COMMENT '当前执行归属的 Worker 会话标识',
|
||||
`token_digest` BINARY(32) NOT NULL COMMENT '执行秘密令牌的 SHA-256 摘要,不输出原令牌',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'LEASED' COMMENT '当前执行尝试的状态,不能代替整个任务的状态',
|
||||
`lease_until` DATETIME(6) NOT NULL COMMENT '已确认的租约截止时间,必须用数据库时间核验',
|
||||
`authorized_at` DATETIME(6) NULL COMMENT '平台允许处理器开始的时间;不能证明业务副作用已经发生',
|
||||
`ended_at` DATETIME(6) NULL COMMENT '本次尝试结束时间;尚未结束时为空',
|
||||
`error_code` VARCHAR(64) NULL COMMENT '稳定错误分类,供恢复策略判断;不得写入敏感原始错误',
|
||||
`result` JSON NULL COMMENT '已保存的结果或结果引用 JSON;未取得结果时为空',
|
||||
`result_digest` BINARY(32) NULL COMMENT '结果语义摘要,用于识别确认重报与冲突结果',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_attempt_epoch (namespace_id, task_id, epoch),
|
||||
UNIQUE KEY uq_attempt_task_id (namespace_id, task_id, id),
|
||||
CONSTRAINT ck_pulse_attempts_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_attempts_1 CHECK (state IN ('LEASED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'EXPIRED', 'UNKNOWN')),
|
||||
CONSTRAINT ck_pulse_attempts_2 CHECK (epoch > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次任务执行尝试,保存租约、授权代次与独立结果';
|
||||
|
||||
CREATE TABLE pulse_attempt_receipts (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`attempt_id` BINARY(16) NOT NULL COMMENT '关联的独立执行尝试标识',
|
||||
`operation` VARCHAR(32) NOT NULL COMMENT '稳定操作类型,用于区分不同接口的幂等作用域',
|
||||
`message_id` BINARY(16) NOT NULL COMMENT '可靠操作消息身份,跨重连重报时保持稳定',
|
||||
`request_digest` BINARY(32) NOT NULL COMMENT '规范化请求语义的 SHA-256 摘要,用于识别同键不同内容',
|
||||
`response` JSON NOT NULL COMMENT '首次可靠提交后的响应 JSON,供重复请求补取确认',
|
||||
`expires_at` DATETIME(6) NOT NULL COMMENT '过期时间,统一 UTC;到期后的行为由对应资源策略决定',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_attempt_receipt (namespace_id, attempt_id, operation, message_id),
|
||||
CONSTRAINT ck_pulse_attempt_receipts_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='执行操作的持久化确认,用于断线或确认丢失后的幂等补取';
|
||||
|
||||
CREATE TABLE pulse_concurrency_limits (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`scope_key` VARCHAR(64) NOT NULL COMMENT '资源作用域标识;空字符串表示当前操作的默认作用域',
|
||||
`key_digest` BINARY(32) NOT NULL COMMENT '稳定幂等键或资源键的 SHA-256 摘要',
|
||||
`max_permits` INT UNSIGNED NOT NULL COMMENT '该共享资源允许占用的许可上限',
|
||||
`used_permits` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已占用的有效许可数,必须与许可记录同事务维护',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_limit_key (namespace_id, scope_key, key_digest),
|
||||
CONSTRAINT ck_pulse_concurrency_limits_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_concurrency_limits_1 CHECK (max_permits > 0 AND used_permits <= max_permits)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='跨节点共享的并发限制,实际额度更新必须处于事务内';
|
||||
|
||||
CREATE TABLE pulse_permits (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`limit_id` BINARY(16) NOT NULL COMMENT '关联的并发限制记录标识',
|
||||
`attempt_id` BINARY(16) NOT NULL COMMENT '关联的独立执行尝试标识',
|
||||
`lease_until` DATETIME(6) NOT NULL COMMENT '已确认的租约截止时间,必须用数据库时间核验',
|
||||
`released_at` DATETIME(6) NULL COMMENT '许可释放时间;为空表示尚未记录释放',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_permit_attempt (namespace_id, limit_id, attempt_id),
|
||||
KEY ix_permit_expiry (released_at, lease_until, namespace_id, id),
|
||||
CONSTRAINT ck_pulse_permits_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次执行尝试占用的并发许可及释放历史';
|
||||
|
||||
CREATE TABLE pulse_schedules (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`current_version` BIGINT UNSIGNED NOT NULL COMMENT '当前生效的计划版本号,发布时与版本记录原子更新',
|
||||
`paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止',
|
||||
`next_fire_at` DATETIME(6) NULL COMMENT '下一计划触发时间;没有后续触发时为空',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_schedule_name (namespace_id, name),
|
||||
KEY ix_schedule_due (paused, next_fire_at, namespace_id, id),
|
||||
CONSTRAINT ck_pulse_schedules_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_schedules_1 CHECK (current_version > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='调度计划的当前控制状态,规则内容由不可变版本保存';
|
||||
|
||||
CREATE TABLE pulse_schedule_versions (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`schedule_id` BINARY(16) NOT NULL COMMENT '所属调度计划标识',
|
||||
`version` BIGINT UNSIGNED NOT NULL COMMENT '不可变发布版本;同版本不同内容必须拒绝',
|
||||
`effective_at` DATETIME(6) NOT NULL COMMENT '该计划版本开始生效的时间',
|
||||
`timezone` VARCHAR(64) NOT NULL COMMENT '计划计算使用的 IANA 时区名称',
|
||||
`specification` JSON NOT NULL COMMENT '版本化规则定义 JSON;发布后禁止原地修改',
|
||||
`task_template` JSON NOT NULL COMMENT '每次触发创建任务时采用的输入与策略模板',
|
||||
`misfire_policy` VARCHAR(16) NOT NULL COMMENT '漏跑策略:跳过、合并或有界补跑',
|
||||
`overlap_policy` VARCHAR(16) NOT NULL COMMENT '上次未完成时的策略:并行、串行或跳过',
|
||||
`catchup_limit` INT UNSIGNED NOT NULL COMMENT '单次漏跑补偿上限;补跑策略必须配置正数',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_schedule_version (namespace_id, schedule_id, version),
|
||||
CONSTRAINT ck_pulse_schedule_versions_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_schedule_versions_1 CHECK (version > 0),
|
||||
CONSTRAINT ck_pulse_schedule_versions_2 CHECK (misfire_policy IN ('SKIP', 'COALESCE', 'CATCH_UP')),
|
||||
CONSTRAINT ck_pulse_schedule_versions_3 CHECK (overlap_policy IN ('PARALLEL', 'SERIAL', 'SKIP')),
|
||||
CONSTRAINT ck_pulse_schedule_versions_4 CHECK (misfire_policy <> 'CATCH_UP' OR catchup_limit > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='已发布的不可变计划版本,记录规则与任务模板';
|
||||
|
||||
CREATE TABLE pulse_schedule_occurrences (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`schedule_id` BINARY(16) NOT NULL COMMENT '所属调度计划标识',
|
||||
`schedule_version` BIGINT UNSIGNED NOT NULL COMMENT '该触发点采用的计划版本号',
|
||||
`scheduled_at` DATETIME(6) NOT NULL COMMENT '原计划触发时间,统一 UTC;参与触发去重',
|
||||
`decision` VARCHAR(16) NOT NULL COMMENT '触发处理决定:已触发、已跳过或已合并',
|
||||
`task_id` BINARY(16) NULL COMMENT '关联的 Pulse 任务标识',
|
||||
`covered_until` DATETIME(6) NULL COMMENT '合并触发覆盖的结束边界;没有合并范围时为空',
|
||||
`reason` VARCHAR(1024) NULL COMMENT '操作原因或处理说明,需要保留审计信息',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_occurrence_time (namespace_id, schedule_id, scheduled_at),
|
||||
UNIQUE KEY uq_occurrence_task (namespace_id, task_id),
|
||||
CONSTRAINT ck_pulse_schedule_occurrences_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_pulse_schedule_occurrences_1 CHECK (decision IN ('FIRED', 'SKIPPED', 'COALESCED')),
|
||||
CONSTRAINT ck_pulse_schedule_occurrences_2 CHECK ((decision = 'SKIPPED' AND task_id IS NULL) OR (decision IN ('FIRED', 'COALESCED') AND task_id IS NOT NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一个原计划触发点的处理记录,包含触发、跳过或合并决定';
|
||||
|
||||
CREATE TABLE weave_definitions (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`description` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '定义用途说明,不承载运行状态',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_definition_name (namespace_id, name),
|
||||
CONSTRAINT ck_weave_definitions_0 CHECK (revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='流程定义的稳定身份,不保存可直接执行的业务代码';
|
||||
|
||||
CREATE TABLE weave_definition_versions (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`definition_id` BINARY(16) NOT NULL COMMENT '稳定流程定义标识',
|
||||
`version` VARCHAR(64) NOT NULL COMMENT '不可变发布版本;同版本不同内容必须拒绝',
|
||||
`schema_version` INT UNSIGNED NOT NULL COMMENT '流程定义文档的结构版本',
|
||||
`specification` JSON NOT NULL COMMENT '版本化规则定义 JSON;发布后禁止原地修改',
|
||||
`content_digest` BINARY(32) NOT NULL COMMENT '不可变定义内容的 SHA-256 摘要',
|
||||
`published_by` VARCHAR(128) NOT NULL COMMENT '发布版本的认证主体,用于追溯发布来源',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_definition_version (namespace_id, definition_id, version),
|
||||
UNIQUE KEY uq_definition_version_id (namespace_id, definition_id, id),
|
||||
CONSTRAINT ck_weave_definition_versions_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_definition_versions_1 CHECK (schema_version > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='已发布的不可变流程定义版本';
|
||||
|
||||
CREATE TABLE weave_instances (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`definition_id` BINARY(16) NOT NULL COMMENT '稳定流程定义标识',
|
||||
`definition_version_id` BINARY(16) NOT NULL COMMENT '实例固定使用的定义版本记录,不能随新版本发布而变化',
|
||||
`gate_id` BINARY(16) NOT NULL COMMENT '执行门控标识,用于检查是否允许新的领取与开始',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'RUNNING' COMMENT '流程执行状态,与独立的暂停标记同时展示',
|
||||
`paused` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '暂停标记;停止新的受控工作,不表示在途请求已停止',
|
||||
`control_revision` BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '暂停、继续等控制操作的版本,避免旧操作覆盖新决定',
|
||||
`input` JSON NOT NULL COMMENT '本次实例或步骤激活的输入快照 JSON',
|
||||
`output` JSON NULL COMMENT '已保存的输出 JSON;未完成时可以为空',
|
||||
`policy_snapshot` JSON NOT NULL COMMENT '受理时冻结的实际生效策略 JSON',
|
||||
`business_key` VARCHAR(191) NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等',
|
||||
`deadline_at` DATETIME(6) NOT NULL COMMENT '流程总截止时间,暂停默认不延长此业务边界',
|
||||
`cancel_requested_at` DATETIME(6) NULL COMMENT '取消意图的持久化时间;不等于取消已完成',
|
||||
`completed_at` DATETIME(6) NULL COMMENT '终结时间;非终态为空,进入终态时必须写入',
|
||||
`retry_of` BINARY(16) NULL COMMENT '来源记录标识,人工重新发起时用于保留历史关联',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_workflow_gate (namespace_id, gate_id),
|
||||
KEY ix_workflow_state (namespace_id, state, updated_at, id),
|
||||
KEY ix_workflow_business (namespace_id, business_key, created_at, id),
|
||||
CONSTRAINT ck_weave_instances_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_instances_1 CHECK (state IN ('RUNNING', 'WAITING', 'CANCELLING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED')),
|
||||
CONSTRAINT ck_weave_instances_2 CHECK (control_revision > 0),
|
||||
CONSTRAINT ck_weave_instances_3 CHECK ((state IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NOT NULL) OR (state NOT IN ('SUCCEEDED', 'FAILED', 'CANCELLED') AND completed_at IS NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='流程运行实例,固定定义版本,暂停与执行状态分别保存';
|
||||
|
||||
CREATE TABLE weave_steps (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`key` VARCHAR(128) NOT NULL COMMENT '定义中的稳定步骤键,在同一流程实例内唯一',
|
||||
`kind` VARCHAR(32) NOT NULL COMMENT '节点或等待类型,决定由哪类推进规则处理',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'BLOCKED' COMMENT '当前状态;只允许定义的状态转换并配合事务校验',
|
||||
`current_activation_id` BINARY(16) NULL COMMENT '当前激活记录,必须属于本实例的本步骤',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_step_key (namespace_id, workflow_id, `key`),
|
||||
UNIQUE KEY uq_step_workflow_id (namespace_id, workflow_id, id),
|
||||
CONSTRAINT ck_weave_steps_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_steps_1 CHECK (state IN ('BLOCKED', 'READY', 'ACTIVE', 'WAITING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'SKIPPED')),
|
||||
CONSTRAINT ck_weave_steps_2 CHECK (kind IN ('TASK', 'BRANCH', 'PARALLEL', 'JOIN', 'SIGNAL', 'TIMER', 'POLL'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='实例中的逻辑步骤,指向该步骤当前的激活记录';
|
||||
|
||||
CREATE TABLE weave_activations (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`step_id` BINARY(16) NOT NULL COMMENT '所属逻辑步骤标识',
|
||||
`sequence` INT UNSIGNED NOT NULL COMMENT '同一步骤的激活序号,人工恢复递增并保留旧记录',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'READY' COMMENT '当前状态;只允许定义的状态转换并配合事务校验',
|
||||
`task_id` BINARY(16) NULL COMMENT '关联的 Pulse 任务标识',
|
||||
`input` JSON NOT NULL COMMENT '本次实例或步骤激活的输入快照 JSON',
|
||||
`output` JSON NULL COMMENT '已保存的输出 JSON;未完成时可以为空',
|
||||
`completed_at` DATETIME(6) NULL COMMENT '终结时间;非终态为空,进入终态时必须写入',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_activation_sequence (namespace_id, step_id, sequence),
|
||||
UNIQUE KEY uq_activation_workflow_id (namespace_id, workflow_id, id),
|
||||
UNIQUE KEY uq_activation_step_id (namespace_id, workflow_id, step_id, id),
|
||||
UNIQUE KEY uq_activation_task (namespace_id, task_id),
|
||||
CONSTRAINT ck_weave_activations_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_activations_1 CHECK (state IN ('BLOCKED', 'READY', 'ACTIVE', 'WAITING', 'NEEDS_REVIEW', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'SKIPPED')),
|
||||
CONSTRAINT ck_weave_activations_2 CHECK (sequence > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='步骤的一次具体激活,人工恢复通过新激活保留历史';
|
||||
|
||||
CREATE TABLE weave_waits (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`activation_id` BINARY(16) NOT NULL COMMENT '所属步骤激活,区分旧轮次和人工恢复后的新轮次',
|
||||
`kind` VARCHAR(16) NOT NULL COMMENT '节点或等待类型,决定由哪类推进规则处理',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'WAITING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验',
|
||||
`correlation_key` VARCHAR(191) NOT NULL COMMENT '外部业务操作关联键,防止旧信号误用于新工作',
|
||||
`deadline_at` DATETIME(6) NOT NULL COMMENT '等待截止时间,用可靠受理时间判断信号是否按时到达',
|
||||
`wake_task_id` BINARY(16) NULL COMMENT '内部定时唤醒任务;无需定时唤醒时为空',
|
||||
`resolved_at` DATETIME(6) NULL COMMENT '等待或查询节点结清的时间;尚未结清时为空',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
KEY ix_wait_deadline (state, deadline_at, namespace_id, id),
|
||||
UNIQUE KEY uq_wait_workflow_id (namespace_id, workflow_id, id),
|
||||
CONSTRAINT ck_weave_waits_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_waits_1 CHECK (state IN ('WAITING', 'SATISFIED', 'EXPIRED', 'CANCELLED')),
|
||||
CONSTRAINT ck_weave_waits_2 CHECK (kind IN ('SIGNAL', 'TIMER', 'POLL'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='可恢复的信号或时间等待,不长期占用业务 Worker';
|
||||
|
||||
CREATE TABLE weave_signals (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`key_digest` BINARY(32) NOT NULL COMMENT '稳定幂等键或资源键的 SHA-256 摘要',
|
||||
`request_digest` BINARY(32) NOT NULL COMMENT '规范化请求语义的 SHA-256 摘要,用于识别同键不同内容',
|
||||
`type` VARCHAR(64) NOT NULL COMMENT '事件或信号的稳定类型标识',
|
||||
`target_step_key` VARCHAR(128) NOT NULL COMMENT '信号目标步骤键,用于早到信号的后续匹配',
|
||||
`correlation_key` VARCHAR(191) NOT NULL COMMENT '外部业务操作关联键,防止旧信号误用于新工作',
|
||||
`payload` JSON NOT NULL COMMENT '版本化输入或事件内容;仅承载通用 JSON,不解释行业规则',
|
||||
`actor_id` VARCHAR(128) NOT NULL COMMENT '经认证的操作主体标识,不能直接信任请求自报值',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验',
|
||||
`accepted_at` DATETIME(6) NOT NULL COMMENT '信号可靠受理时间,不能用稍后的消费时间替代',
|
||||
`expires_at` DATETIME(6) NOT NULL COMMENT '过期时间,统一 UTC;到期后的行为由对应资源策略决定',
|
||||
`consumed_wait_id` BINARY(16) NULL COMMENT '消费该信号的等待记录;尚未消费时为空',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_signal_identity (namespace_id, workflow_id, key_digest),
|
||||
KEY ix_signal_match (namespace_id, workflow_id, target_step_key, correlation_key, state, accepted_at),
|
||||
CONSTRAINT ck_weave_signals_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_signals_1 CHECK (state IN ('PENDING', 'CONSUMED', 'EXPIRED', 'REJECTED')),
|
||||
CONSTRAINT ck_weave_signals_2 CHECK ((state = 'CONSUMED' AND consumed_wait_id IS NOT NULL) OR (state <> 'CONSUMED' AND consumed_wait_id IS NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='已可靠受理的外部信号,受理与消费是两个阶段';
|
||||
|
||||
CREATE TABLE weave_poll_states (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`activation_id` BINARY(16) NOT NULL COMMENT '所属步骤激活,区分旧轮次和人工恢复后的新轮次',
|
||||
`max_rounds` INT UNSIGNED NOT NULL COMMENT '业务查询最多轮次,不等于单轮请求重试次数',
|
||||
`used_rounds` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已创建的查询轮次,重启或暂停继续不得重置',
|
||||
`max_attempts_per_round` INT UNSIGNED NOT NULL COMMENT '每轮 Pulse 任务最多尝试数,包含该轮首次请求',
|
||||
`interval_ms` BIGINT UNSIGNED NOT NULL COMMENT '相邻业务查询轮次的等待间隔,单位毫秒',
|
||||
`deadline_at` DATETIME(6) NOT NULL COMMENT '查询节点总截止时间,轮次和单轮重试都受此边界限制',
|
||||
`next_poll_at` DATETIME(6) NULL COMMENT '最早允许下一轮查询的时间;为空表示当前不能直接推进',
|
||||
`current_task_id` BINARY(16) NULL COMMENT '当前在途查询任务;非空时禁止重叠开启下一轮',
|
||||
`resolved_at` DATETIME(6) NULL COMMENT '等待或查询节点结清的时间;尚未结清时为空',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_poll_activation (namespace_id, workflow_id, activation_id),
|
||||
CONSTRAINT ck_weave_poll_states_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_poll_states_1 CHECK (max_rounds > 0 AND used_rounds <= max_rounds),
|
||||
CONSTRAINT ck_weave_poll_states_2 CHECK (max_attempts_per_round > 0 AND interval_ms > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='有界查询节点的持久化预算与恢复检查点';
|
||||
|
||||
CREATE TABLE weave_poll_rounds (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`activation_id` BINARY(16) NOT NULL COMMENT '所属步骤激活,区分旧轮次和人工恢复后的新轮次',
|
||||
`round` INT UNSIGNED NOT NULL COMMENT '本次激活内的查询轮次编号,从 1 开始',
|
||||
`task_id` BINARY(16) NOT NULL COMMENT '关联的 Pulse 任务标识',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_poll_round (namespace_id, workflow_id, activation_id, round),
|
||||
UNIQUE KEY uq_poll_task (namespace_id, task_id),
|
||||
CONSTRAINT ck_weave_poll_rounds_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_poll_rounds_1 CHECK (round > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='一次业务查询轮次,一轮对应一个 Pulse 任务';
|
||||
|
||||
CREATE TABLE weave_inbox (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`event_id` BINARY(16) NOT NULL COMMENT '来源持久化事件标识,用于消费去重',
|
||||
`state` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '当前状态;只允许定义的状态转换并配合事务校验',
|
||||
`available_at` DATETIME(6) NOT NULL COMMENT '最早可被领取或处理的时间,统一 UTC',
|
||||
`lease_owner` BINARY(16) NULL COMMENT '租约持有者标识;未持有租约时为空',
|
||||
`lease_epoch` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '递增的执行或消费代次,用于隔离旧授权',
|
||||
`lease_until` DATETIME(6) NULL COMMENT '内部消费租约截止时间;没有消费租约时为空',
|
||||
`processed_at` DATETIME(6) NULL COMMENT '内部事件完成消费的时间;尚未消费时为空',
|
||||
`error_code` VARCHAR(64) NULL COMMENT '稳定错误分类,供恢复策略判断;不得写入敏感原始错误',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_inbox_event (namespace_id, event_id),
|
||||
KEY ix_inbox_due (state, available_at, namespace_id, id),
|
||||
CONSTRAINT ck_weave_inbox_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_inbox_1 CHECK (state IN ('PENDING', 'LEASED', 'PROCESSED', 'QUARANTINED')),
|
||||
CONSTRAINT ck_weave_inbox_2 CHECK ((state = 'LEASED' AND lease_owner IS NOT NULL AND lease_until IS NOT NULL) OR (state <> 'LEASED' AND lease_owner IS NULL AND lease_until IS NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='Weave 内部事件消费记录,和流程推进一起确认消费';
|
||||
|
||||
CREATE TABLE weave_pending_advances (
|
||||
namespace_id BINARY(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
id BINARY(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始;更新时必须核对预期版本',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC,数据库精度为微秒',
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`workflow_id` BINARY(16) NOT NULL COMMENT '所属流程实例标识',
|
||||
`step_id` BINARY(16) NOT NULL COMMENT '所属逻辑步骤标识',
|
||||
`expected_step_revision` BIGINT UNSIGNED NOT NULL COMMENT '恢复推进时要求的步骤版本,避免使用过期检查点',
|
||||
`checkpoint` JSON NOT NULL COMMENT '分批或暂停后继续推进所需的持久化 JSON 检查点',
|
||||
`available_at` DATETIME(6) NOT NULL COMMENT '最早可被领取或处理的时间,统一 UTC',
|
||||
PRIMARY KEY (namespace_id, id),
|
||||
UNIQUE KEY uq_advance_step (namespace_id, workflow_id, step_id),
|
||||
KEY ix_advance_due (available_at, namespace_id, id),
|
||||
CONSTRAINT ck_weave_pending_advances_0 CHECK (revision > 0),
|
||||
CONSTRAINT ck_weave_pending_advances_1 CHECK (expected_step_revision > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='暂停或分批推进后的续接检查点,避免仅依赖内存唤醒';
|
||||
|
||||
ALTER TABLE orbit_namespaces ADD CONSTRAINT fk_orbit_namespaces_0
|
||||
FOREIGN KEY (application_id) REFERENCES orbit_applications (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_credentials ADD CONSTRAINT fk_orbit_credentials_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_idempotency_keys ADD CONSTRAINT fk_orbit_idempotency_keys_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_events ADD CONSTRAINT fk_orbit_events_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_subscriptions ADD CONSTRAINT fk_orbit_subscriptions_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_subscriptions ADD CONSTRAINT fk_orbit_subscriptions_1
|
||||
FOREIGN KEY (namespace_id, credential_id) REFERENCES orbit_credentials (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_deliveries ADD CONSTRAINT fk_orbit_deliveries_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_deliveries ADD CONSTRAINT fk_orbit_deliveries_1
|
||||
FOREIGN KEY (namespace_id, subscription_id) REFERENCES orbit_subscriptions (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_deliveries ADD CONSTRAINT fk_orbit_deliveries_2
|
||||
FOREIGN KEY (namespace_id, event_id) REFERENCES orbit_events (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE orbit_audit_events ADD CONSTRAINT fk_orbit_audit_events_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_queues ADD CONSTRAINT fk_pulse_queues_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_execution_gates ADD CONSTRAINT fk_pulse_execution_gates_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_worker_sessions ADD CONSTRAINT fk_pulse_worker_sessions_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_worker_sessions ADD CONSTRAINT fk_pulse_worker_sessions_1
|
||||
FOREIGN KEY (namespace_id, credential_id) REFERENCES orbit_credentials (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_1
|
||||
FOREIGN KEY (namespace_id, queue_id) REFERENCES pulse_queues (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_2
|
||||
FOREIGN KEY (namespace_id, gate_id) REFERENCES pulse_execution_gates (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_3
|
||||
FOREIGN KEY (namespace_id, retry_of) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_tasks ADD CONSTRAINT fk_pulse_tasks_4
|
||||
FOREIGN KEY (namespace_id, id, current_attempt_id) REFERENCES pulse_attempts (namespace_id, task_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_attempts ADD CONSTRAINT fk_pulse_attempts_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_attempts ADD CONSTRAINT fk_pulse_attempts_1
|
||||
FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_attempts ADD CONSTRAINT fk_pulse_attempts_2
|
||||
FOREIGN KEY (namespace_id, session_id) REFERENCES pulse_worker_sessions (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_attempt_receipts ADD CONSTRAINT fk_pulse_attempt_receipts_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_attempt_receipts ADD CONSTRAINT fk_pulse_attempt_receipts_1
|
||||
FOREIGN KEY (namespace_id, attempt_id) REFERENCES pulse_attempts (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_concurrency_limits ADD CONSTRAINT fk_pulse_concurrency_limits_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_permits ADD CONSTRAINT fk_pulse_permits_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_permits ADD CONSTRAINT fk_pulse_permits_1
|
||||
FOREIGN KEY (namespace_id, limit_id) REFERENCES pulse_concurrency_limits (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_permits ADD CONSTRAINT fk_pulse_permits_2
|
||||
FOREIGN KEY (namespace_id, attempt_id) REFERENCES pulse_attempts (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_schedules ADD CONSTRAINT fk_pulse_schedules_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_schedule_versions ADD CONSTRAINT fk_pulse_schedule_versions_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_schedule_versions ADD CONSTRAINT fk_pulse_schedule_versions_1
|
||||
FOREIGN KEY (namespace_id, schedule_id) REFERENCES pulse_schedules (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_schedule_occurrences ADD CONSTRAINT fk_pulse_schedule_occurrences_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_schedule_occurrences ADD CONSTRAINT fk_pulse_schedule_occurrences_1
|
||||
FOREIGN KEY (namespace_id, schedule_id, schedule_version) REFERENCES pulse_schedule_versions (namespace_id, schedule_id, version) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE pulse_schedule_occurrences ADD CONSTRAINT fk_pulse_schedule_occurrences_2
|
||||
FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_definitions ADD CONSTRAINT fk_weave_definitions_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_definition_versions ADD CONSTRAINT fk_weave_definition_versions_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_definition_versions ADD CONSTRAINT fk_weave_definition_versions_1
|
||||
FOREIGN KEY (namespace_id, definition_id) REFERENCES weave_definitions (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_1
|
||||
FOREIGN KEY (namespace_id, definition_id, definition_version_id) REFERENCES weave_definition_versions (namespace_id, definition_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_2
|
||||
FOREIGN KEY (namespace_id, gate_id) REFERENCES pulse_execution_gates (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_instances ADD CONSTRAINT fk_weave_instances_3
|
||||
FOREIGN KEY (namespace_id, retry_of) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_steps ADD CONSTRAINT fk_weave_steps_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_steps ADD CONSTRAINT fk_weave_steps_1
|
||||
FOREIGN KEY (namespace_id, workflow_id) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_steps ADD CONSTRAINT fk_weave_steps_2
|
||||
FOREIGN KEY (namespace_id, workflow_id, id, current_activation_id) REFERENCES weave_activations (namespace_id, workflow_id, step_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_activations ADD CONSTRAINT fk_weave_activations_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_activations ADD CONSTRAINT fk_weave_activations_1
|
||||
FOREIGN KEY (namespace_id, workflow_id, step_id) REFERENCES weave_steps (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_activations ADD CONSTRAINT fk_weave_activations_2
|
||||
FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_waits ADD CONSTRAINT fk_weave_waits_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_waits ADD CONSTRAINT fk_weave_waits_1
|
||||
FOREIGN KEY (namespace_id, workflow_id, activation_id) REFERENCES weave_activations (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_waits ADD CONSTRAINT fk_weave_waits_2
|
||||
FOREIGN KEY (namespace_id, wake_task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_signals ADD CONSTRAINT fk_weave_signals_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_signals ADD CONSTRAINT fk_weave_signals_1
|
||||
FOREIGN KEY (namespace_id, workflow_id) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_signals ADD CONSTRAINT fk_weave_signals_2
|
||||
FOREIGN KEY (namespace_id, workflow_id, consumed_wait_id) REFERENCES weave_waits (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_poll_states ADD CONSTRAINT fk_weave_poll_states_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_poll_states ADD CONSTRAINT fk_weave_poll_states_1
|
||||
FOREIGN KEY (namespace_id, workflow_id, activation_id) REFERENCES weave_activations (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_poll_states ADD CONSTRAINT fk_weave_poll_states_2
|
||||
FOREIGN KEY (namespace_id, current_task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_poll_rounds ADD CONSTRAINT fk_weave_poll_rounds_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_poll_rounds ADD CONSTRAINT fk_weave_poll_rounds_1
|
||||
FOREIGN KEY (namespace_id, workflow_id, activation_id) REFERENCES weave_poll_states (namespace_id, workflow_id, activation_id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_poll_rounds ADD CONSTRAINT fk_weave_poll_rounds_2
|
||||
FOREIGN KEY (namespace_id, task_id) REFERENCES pulse_tasks (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_inbox ADD CONSTRAINT fk_weave_inbox_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_inbox ADD CONSTRAINT fk_weave_inbox_1
|
||||
FOREIGN KEY (namespace_id, workflow_id) REFERENCES weave_instances (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_inbox ADD CONSTRAINT fk_weave_inbox_2
|
||||
FOREIGN KEY (namespace_id, event_id) REFERENCES orbit_events (namespace_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_pending_advances ADD CONSTRAINT fk_weave_pending_advances_0
|
||||
FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE weave_pending_advances ADD CONSTRAINT fk_weave_pending_advances_1
|
||||
FOREIGN KEY (namespace_id, workflow_id, step_id) REFERENCES weave_steps (namespace_id, workflow_id, id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||
|
||||
-- Published versions and event history are append-only. Retention deletes require
|
||||
-- a separate administrative identity and must respect active references.
|
||||
CREATE TRIGGER trg_definition_version_immutable BEFORE UPDATE ON weave_definition_versions
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Published workflow versions are immutable';
|
||||
CREATE TRIGGER trg_schedule_version_immutable BEFORE UPDATE ON pulse_schedule_versions
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Published schedule versions are immutable';
|
||||
CREATE TRIGGER trg_audit_event_immutable BEFORE UPDATE ON orbit_audit_events
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Audit events are append-only';
|
||||
CREATE TRIGGER trg_event_immutable BEFORE UPDATE ON orbit_events
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'State events are append-only';
|
||||
99
internal/storage/mysql/sqlsplit.go
Normal file
99
internal/storage/mysql/sqlsplit.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// splitSQL supports ordinary migration statements, quoted values and comments.
|
||||
// Stored routines and DELIMITER scripts are intentionally unsupported.
|
||||
func splitSQL(src string) ([]string, error) {
|
||||
var out []string
|
||||
var b strings.Builder
|
||||
var quote byte
|
||||
lineComment, blockComment := false, false
|
||||
flush := func() error {
|
||||
s := strings.TrimSpace(b.String())
|
||||
b.Reset()
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToUpper(s), "DELIMITER") {
|
||||
return errors.New("DELIMITER scripts are unsupported")
|
||||
}
|
||||
out = append(out, s)
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(src); i++ {
|
||||
c := src[i]
|
||||
if lineComment {
|
||||
if c == '\n' {
|
||||
lineComment = false
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if blockComment {
|
||||
if c == '*' && i+1 < len(src) && src[i+1] == '/' {
|
||||
blockComment = false
|
||||
i++
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
b.WriteByte(c)
|
||||
if c == '\\' && i+1 < len(src) {
|
||||
i++
|
||||
b.WriteByte(src[i])
|
||||
continue
|
||||
}
|
||||
if c == quote {
|
||||
if i+1 < len(src) && src[i+1] == quote {
|
||||
i++
|
||||
b.WriteByte(src[i])
|
||||
} else {
|
||||
quote = 0
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '#' || c == '-' && i+2 < len(src) && src[i+1] == '-' && src[i+2] <= ' ' {
|
||||
lineComment = true
|
||||
if c == '-' {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '/' && i+1 < len(src) && src[i+1] == '*' {
|
||||
if i+2 < len(src) && src[i+2] == '!' {
|
||||
return nil, errors.New("executable SQL comments are unsupported")
|
||||
}
|
||||
blockComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '\'' || c == '"' || c == '`' {
|
||||
quote = c
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
if c == ';' {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteByte(c)
|
||||
}
|
||||
if quote != 0 || blockComment {
|
||||
return nil, errors.New("unterminated SQL quote or comment")
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("empty migration")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
19
internal/storage/mysql/sqlsplit_test.go
Normal file
19
internal/storage/mysql/sqlsplit_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package mysql
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSQLSplitterPreservesQuotedSemicolons(t *testing.T) {
|
||||
sql := "-- comment;\nCREATE TABLE `x;y` (v VARCHAR(10)); INSERT INTO `x;y` VALUES ('a;''b'); /* ignore ; */"
|
||||
s, err := splitSQL(sql)
|
||||
if err != nil || len(s) != 2 {
|
||||
t.Fatalf("split=%v err=%v", s, err)
|
||||
}
|
||||
if s[1] != "INSERT INTO `x;y` VALUES ('a;''b')" {
|
||||
t.Fatal(s[1])
|
||||
}
|
||||
for _, bad := range []string{"SELECT 'broken;", "/*broken", "/*! DROP TABLE x */", "DELIMITER $$", "-- empty"} {
|
||||
if _, err := splitSQL(bad); err == nil {
|
||||
t.Fatalf("accepted %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
internal/transport/httpserver/server.go
Normal file
34
internal/transport/httpserver/server.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Package httpserver exposes bootstrap health endpoints only. Task, workflow,
|
||||
// authentication and WebSocket APIs will be implemented in subsequent slices.
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Checker func(context.Context) error
|
||||
|
||||
func New(addr string, ready Checker) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
write := func(w http.ResponseWriter, status int, body map[string]string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
mux.HandleFunc("GET /health/live", func(w http.ResponseWriter, r *http.Request) {
|
||||
write(w, http.StatusOK, map[string]string{"status": "alive"})
|
||||
})
|
||||
mux.HandleFunc("GET /health/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if ready == nil || ready(ctx) != nil {
|
||||
write(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, map[string]string{"status": "ready", "scope": "database_and_schema"})
|
||||
})
|
||||
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
}
|
||||
21
internal/transport/httpserver/server_test.go
Normal file
21
internal/transport/httpserver/server_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthSeparatesLivenessAndReadiness(t *testing.T) {
|
||||
server := New("127.0.0.1:0", func(context.Context) error { return errors.New("internal-database-details") })
|
||||
for path, want := range map[string]int{"/health/live": 200, "/health/ready": 503, "/v1/tasks": 404} {
|
||||
rr := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rr.Code != want || strings.Contains(rr.Body.String(), "internal-database-details") {
|
||||
t.Fatalf("%s: %d %s", path, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user