259 lines
10 KiB
Go
259 lines
10 KiB
Go
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("no_foreign_keys_and_safe_task_states", func(t *testing.T) {
|
|
var count int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("foreign keys must be absent: %d %v", count, err)
|
|
}
|
|
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.WorkerInstance{},
|
|
pulse.Task{},
|
|
pulse.Attempt{},
|
|
pulse.AttemptReceipt{},
|
|
pulse.AttemptEvidence{},
|
|
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)
|
|
}
|
|
}
|
|
}
|