13 Commits

124 changed files with 9409 additions and 471 deletions

View File

@@ -18,6 +18,26 @@ ORBIT_EVENT_CONNECTION_LIMIT=128
# 每个 WebSocket Hub 的数据库领取并发和有界内存排队,范围分别为1–256、1–65536。
ORBIT_DISPATCH_WORKERS=4
ORBIT_DISPATCH_CAPACITY=128
# 归档策略首次启动时的数据库默认值;保存到管理端后,以数据库版本为准。
# Enabled 没有环境变量,默认关闭;窗口仅允许1–3650天,幂等记录不得少于90天。
ORBIT_RETENTION_IDEMPOTENCY_DAYS=90
ORBIT_RETENTION_TASK_DAYS=365
ORBIT_RETENTION_WORKFLOW_DAYS=365
ORBIT_RETENTION_EVENT_DAYS=365
ORBIT_RETENTION_DELIVERY_DAYS=90
ORBIT_RETENTION_INBOX_DAYS=90
ORBIT_RETENTION_AUDIT_DAYS=2555
# Empty disables metrics. To enable a separate private listener, use e.g.
# ORBIT_METRICS_ADDR=127.0.0.1:9090. Never expose it on the public API ingress.
ORBIT_METRICS_ADDR=
# 仅供 orbit-admin retention archive / verify-archive 使用;每个作业单独的绝对目录。
# 不会启动后台定时归档,完整参数见 docs/api/retention-jobs.md。
# ORBIT_ARCHIVE_DIRECTORY=/srv/orbit-archives/tasks-20260923
# 周期归档:持久卷目录和固定卷ID必须一起配置,默认关闭。
# 镜像用户65532须有目录权限;共享卷节点必须使用同一物理卷。
ORBIT_ARCHIVE_ROOT=
ORBIT_ARCHIVE_STORAGE_ID=
ORBIT_ARCHIVE_MAX_BYTES=10737418240
ORBIT_ARCHIVE_TICK_INTERVAL=10s

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@
*.log
/.local/
*.credentials.json
/examples/http-service/server

View File

@@ -26,6 +26,7 @@
- 任务来源通过独立只读路由查询,tasks:read仅授予当前任务的有限来源引用;计划定义仍需schedules:read。显式核对命名空间、队列、计划、触发与固定版本,悬空/错误/未实现所有者不得默认为DIRECT;不依赖外键,也不向派发路径加入来源查询。
- 业务事务Outbox示例使用独立module和业务数据库,核心SDK不得引入SQL驱动。业务记录/待发命令同事务;原请求、操作ID、目标及提交键冻结,HTTP在事务外,确认按最新租约代次与业务关联同事务。24小时/100次预算内原键核对,禁止换键、重置预算或把ACCEPTED解释成业务成功;REVIEW保留待核查数据。
- 可复用业务Outbox位于 adapters/outbox/mysql 独立module。EnqueueTx只写调用方事务,不Commit/Rollback/HTTP;重复核对使用共享锁当前读,避免重复INSERT锁升级死锁。受理钩子与最新代次确认同事务,只允许同库短SQL,固定Outbox→业务行锁序。旧CLI与HTTP宿主共用发送核心;宿主关闭顺序为HTTP收尾→发送/执行收尾→结果消费收尾→数据库关闭,未结清记录保留原身份。
- 相对延迟delay_ms只在首次受理事务按数据库时间计算;原时长参与摘要,零值omitempty保持旧摘要。重报先返回原回执,禁止重算执行时间;总时限包含等待。计划模板拒绝非零delay_ms,人工重试不自动继承首次等待。

View File

@@ -1,25 +1,33 @@
GO ?= go
.PHONY: fmt test vet build image integration mysql-replication mysql-compatibility sdk-check protocol-check docs-check schedule-backlog release-manifest release-local-check
.PHONY: fmt test vet build adapters-check image integration console-e2e compose-check mysql-replication mysql-compatibility sdk-check protocol-check docs-check stability-check schedule-backlog release-manifest release-local-check
fmt:
$(GO) fmt ./...
cd sdk/go && $(GO) fmt ./...
cd examples/result-inbox && GOWORK=off $(GO) fmt ./...
cd examples/task-outbox && GOWORK=off $(GO) fmt ./...
cd adapters/outbox/mysql && GOWORK=off $(GO) fmt ./...
cd examples/http-service && GOWORK=off $(GO) fmt ./...
test:
$(GO) test -race ./...
cd sdk/go && $(GO) test -race ./...
cd examples/result-inbox && GOWORK=off $(GO) test -race ./...
cd examples/task-outbox && GOWORK=off $(GO) test -race ./...
cd adapters/outbox/mysql && GOWORK=off $(GO) test -race ./...
cd examples/http-service && GOWORK=off $(GO) test -race ./...
vet:
$(GO) vet ./...
cd sdk/go && $(GO) vet ./...
cd examples/result-inbox && GOWORK=off $(GO) vet ./...
cd examples/task-outbox && GOWORK=off $(GO) vet ./...
cd adapters/outbox/mysql && GOWORK=off $(GO) vet ./...
cd examples/http-service && GOWORK=off $(GO) vet ./...
build:
cd adapters/outbox/mysql && GOWORK=off $(GO) build ./...
cd examples/http-service && GOWORK=off $(GO) build -o ../../bin/orbit-http-example ./cmd/server
$(GO) build -o bin/orbit ./cmd/orbit
$(GO) build -o bin/orbit-migrate ./cmd/orbit-migrate
$(GO) build -o bin/orbit-admin ./cmd/orbit-admin
@@ -32,6 +40,18 @@ image:
integration:
./scripts/test-mysql.sh
console-e2e:
./scripts/test-console-e2e.sh
stability-check:
./scripts/test-stability.sh
compose-check:
@test -n "$${ORBIT_DB_USER:-}" || (echo 'ORBIT_DB_USER is required' >&2; exit 1)
@test -n "$${ORBIT_DB_PASSWORD:-}" || (echo 'ORBIT_DB_PASSWORD is required' >&2; exit 1)
@test -n "$${ORBIT_DB_ROOT_PASSWORD:-}" || (echo 'ORBIT_DB_ROOT_PASSWORD is required' >&2; exit 1)
@docker compose -f deploy/docker-compose.yml config >/dev/null
mysql-replication:
./scripts/test-mysql-replication.sh
@@ -56,3 +76,6 @@ release-local-check:
# Runs the bounded, intentionally incomplete persistent backlog rehearsal in an isolated MySQL.
schedule-backlog:
./scripts/test-schedule-backlog.sh
adapters-check:
./scripts/test-adapters.sh

View File

@@ -39,6 +39,8 @@ internal/config/ 配置校验
internal/storage/mysql/ 连接、事务、迁移及集成测试
internal/transport/httpserver/ HTTP 接入
sdk/go/ 独立 Go SDK module 与示例
adapters/outbox/mysql/ 可直接引用的业务事务 Outbox 独立模块
examples/http-service/ HTTP + Outbox + Worker + Inbox 完整服务示例
scripts/ 可复现的回归工具
docs/ 设计、接口、SDK、场景与验证报告
```
@@ -74,6 +76,7 @@ make vet
make build
make image
make integration
make console-e2e
make protocol-check
make docs-check
```
@@ -84,6 +87,8 @@ make docs-check
受理层负载采样工具见 [有界受理负载工具](docs/api/load.md),只允许在隔离命名空间或一次性数据库中运行;它不自动重试、不启动 Worker,也不提供生产容量承诺。
历史数据可通过控制台配置保留窗口,也可使用 [`orbit-admin retention archive`](docs/api/retention-jobs.md) 自动分批落盘、断点续跑和离线核验。当前保留源数据,周期归档调度和删除执行器另行交付。
真实Worker与任务完成路径测量使用[`orbit-exec-load`](docs/api/execution-load.md):短/混合/中长模拟任务、持久结果和单次尝试核验、阶段延迟分布;不修改队列额度、不调用外部业务系统。定时积压复现可运行 `make schedule-backlog`,它只使用一次性 MySQL,并保留有界截止负例的实际分母。Weave 并行流程链路使用[`orbit-workflow-load`](docs/api/workflow-load.md),核对固定版本、步骤、来源和最终输出。定时集中触发使用[`orbit-schedule-load`](docs/api/schedule-load.md),逐项核对触发、任务、唯一尝试与来源,并保留超时分母和暂停收尾证据。
SDK 独立发布门禁使用 `make sdk-check`,在 `GOWORK=off` 下检查完整依赖图,并运行 SDK 的竞态测试、vet 和全包构建;规则和接入边界见 [SDK 发布门禁](docs/sdk/release.md)。
@@ -111,3 +116,7 @@ go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.0
无需本地 `replace`;导入方式、完整调用与版本边界见 [SDK 接入](docs/sdk/README.md#5-外部项目通过远端接入)。
[流程实例筛选分页](docs/api/workflow-queries.md)已提供独立SDK和[完整示例](docs/examples/workflow-query.md),按固定版本全部队列鉴权,数据库保持零外键。
- [周期归档与作业管理](docs/api/archive-scheduler.md)
业务接入可直接使用[MySQL 事务 Outbox 适配包](docs/sdk/outbox-mysql.md)和[完整 HTTP 生命周期示例](docs/examples/http-service.md),覆盖已有事务、后台发送、结果入库以及有界关闭。

View File

@@ -0,0 +1,10 @@
# Orbit MySQL 事务 Outbox 适配包
独立 Go module:`git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql`,包名 `outbox`。依赖公开 SDK v0.1.0 与 MySQL 驱动,不依赖平台服务端代码;核心 SDK 不反向依赖本包。
- [接口、事务边界、错误与恢复](../../../docs/sdk/outbox-mysql.md)
- [完整 HTTP 服务接入](../../../docs/examples/http-service.md)
- [中文模型](model.go)、[无外键表结构](schema.sql)
- [测试报告](../../../docs/reports/outbox-http-integration.md)
以业务自己的数据库存储待发命令,调用 `EnqueueTx` 加入已有事务,后台运行 `Run` 或 `SendOnce`。本包确认的是平台受理,任务执行完成通过 Worker 和可靠结果订阅衔接。

View File

@@ -0,0 +1,60 @@
package outbox
import (
"context"
"database/sql"
"strings"
"unicode"
"unicode/utf8"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func businessStore(db *sql.DB, namespace, queue string) (*Store, error) {
return New(db, namespace, queue, Options{OnAccepted: accepted})
}
func accepted(ctx context.Context, tx *sql.Tx, r Record, receipt orbit.SubmitTaskResult) error {
var existing *string
if err := tx.QueryRowContext(ctx, `SELECT task_id FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, r.NamespaceID, r.OperationID).Scan(&existing); err != nil {
return ErrStorage
}
if existing != nil && *existing != receipt.Task.ID {
return ErrConflict
}
_, err := tx.ExecContext(ctx, `UPDATE sender_jobs SET task_id=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, receipt.Task.ID, r.NamespaceID, r.OperationID)
if err != nil {
return ErrStorage
}
return nil
}
func (s *Store) Enqueue(ctx context.Context, id, source string, in orbit.SubmitTaskRequest) (bool, error) {
if !utf8.ValidString(source) || strings.TrimSpace(source) == "" || utf8.RuneCountInString(source) > 512 || strings.ContainsFunc(source, unicode.IsControl) {
return false, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return false, ErrStorage
}
defer tx.Rollback()
duplicate, err := s.EnqueueTx(ctx, tx, id, in)
if err != nil {
return false, err
}
if duplicate {
var saved string
if err = tx.QueryRowContext(ctx, `SELECT source_ref FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, s.namespace, id).Scan(&saved); err != nil {
return false, ErrStorage
}
if saved != source {
return false, ErrConflict
}
} else {
if _, err = tx.ExecContext(ctx, `INSERT INTO sender_jobs(namespace_id,operation_id,source_ref,created_at,updated_at) VALUES(?,?,?,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, s.namespace, id, source); err != nil {
return false, ErrStorage
}
}
if tx.Commit() != nil {
return false, ErrCommitUnconfirmed
}
return duplicate, nil
}

View File

@@ -17,7 +17,8 @@ func TestIntegrationSenderCLI(t *testing.T) {
ctx, stop := context.WithTimeout(context.Background(), 30*time.Second)
defer stop()
binary := filepath.Join(t.TempDir(), "sender")
build := exec.CommandContext(ctx, filepath.Join(runtime.GOROOT(), "bin", "go"), "build", "-o", binary, "../../cmd/sender")
build := exec.CommandContext(ctx, filepath.Join(runtime.GOROOT(), "bin", "go"), "build", "-o", binary, "./cmd/sender")
build.Dir = "../../../examples/task-outbox"
if raw, err := build.CombinedOutput(); err != nil {
t.Fatalf("build sender: %v %s", err, raw)
}

View File

@@ -160,7 +160,7 @@ func TestIntegrationSenderCommitResponseLoss(t *testing.T) {
for _, phase := range []string{"enqueue", "claim", "confirm"} {
t.Run(phase, func(t *testing.T) {
s := testStore(t, proxied)
fresh, _ := New(direct, s.namespace, s.queue)
fresh, _ := businessStore(direct, s.namespace, s.queue)
id := testID(t)
in := testRequest(s.queue)
if phase == "enqueue" {

View File

@@ -0,0 +1,13 @@
module git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql
go 1.27.1
require (
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0
github.com/go-sql-driver/mysql v1.10.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/coder/websocket v1.8.15 // indirect
)

View File

@@ -0,0 +1,8 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0 h1:OJp+U8dDNKjiXy4ZhO4MMq68Ek8TkjphUAqsjOOg77c=
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0/go.mod h1:9KlMW7Gy+EGaDoEwr3wHHFY47mnAymSfYeQpMS7h4ag=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=

View File

@@ -53,7 +53,7 @@ func realStore(t *testing.T) (*Store, *orbit.Client, string, string) {
if err != nil {
t.Fatal(err)
}
s, err := New(db, queue.Queue.NamespaceID, queue.Queue.ID)
s, err := businessStore(db, queue.Queue.NamespaceID, queue.Queue.ID)
if err != nil {
t.Fatal(err)
}
@@ -165,7 +165,7 @@ func TestIntegrationSenderHTTPReceiptLoss(t *testing.T) {
t.Fatal("remote duplicate task", len(page.Items), err)
}
// 相同凭据搭配错误的持久化目标命名空间,必须在提交前停止。
wrong, _ := New(s.db, testID(t), s.queue)
wrong, _ := businessStore(s.db, testID(t), s.queue)
wrongID := enqueue(t, wrong)
r, err = wrong.SendOnce(ctx, through)
if err != nil || r.OperationID != wrongID || r.State != "REVIEW" || r.LastCode != "TARGET_MISMATCH" || posts.Load() != 2 {

View File

@@ -0,0 +1,23 @@
package outbox
import "time"
// Record 是业务数据库中的待发命令;只确认平台受理,不能表示业务执行成功。
type Record struct {
NamespaceID string // NamespaceID 固定目标命名空间,换凭据不能改变归属。
QueueID string // QueueID 固定目标队列,发送前通过当前凭据核对归属。
OperationID string // OperationID 调用方持久化的业务操作ID,同命名空间内唯一。
IdempotencyKey string // IdempotencyKey 由操作ID派生的固定提交键,重报禁止换键。
RequestJSON []byte // RequestJSON 冻结的任务提交正文,保留数字精度,不含凭据。
RequestDigest []byte // RequestDigest 冻结正文的SHA256,发送前重新校验,非平台语义摘要。
State string // State PENDING待发、SENDING领取中、ACCEPTED已受理、REVIEW需核查。
SendCount uint32 // SendCount 持久化领取次数,包含领取后未发HTTP即退出的次数,上限100。
LeaseEpoch uint64 // LeaseEpoch 每次领取递增;旧代次不能更新新代次的本地结果。
WakeAt *time.Time // WakeAt PENDING的下次领取时间或SENDING的租约期限;终结状态为空,UTC微秒。
SendDeadlineAt time.Time // SendDeadlineAt 首次入库后24小时停止新发送,短于平台90天幂等窗口。
LastCode string // LastCode 最近一次稳定分类,不保存HTTP错误正文、凭据或业务秘密。
TaskID *string // TaskID 仅ACCEPTED记录平台任务ID,其余状态为空。
ReceiptJSON []byte // ReceiptJSON 仅ACCEPTED保存原受理回执;不是当前执行状态。
CreatedAt time.Time // CreatedAt 业务事务首次提交命令的UTC时间,微秒精度。
UpdatedAt time.Time // UpdatedAt 最近状态变化的UTC时间,不能替代租约代次。
}

View File

@@ -0,0 +1,19 @@
package outbox
import (
"context"
"database/sql"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
// AcceptedHook 在最新租约确认事务内关联业务数据;可能在事务回滚/提交不明后再次调用。
// 必须同库、幂等、遵守 context,只做短 SQL;不能 Commit/Rollback,也不能发网络请求。
// record 是锁定后的发送记录,receipt 是本次已核验的受理回执,不是任务完成结果。
type AcceptedHook func(context.Context, *sql.Tx, Record, orbit.SubmitTaskResult) error
// Options 在构造后固定,Store 可以在多个发送 goroutine 中复用。
type Options struct {
// OnAccepted 可选的业务关联;为空时受理关系仅保存在 Outbox 的 TaskID/ReceiptJSON。
OnAccepted AcceptedHook
}

View File

@@ -45,7 +45,7 @@ func TestSenderProcessHelper(t *testing.T) {
}
db, _ := integrationDB(t)
_, base, token := realClient(t)
s, err := New(db, os.Getenv("ORBIT_TEST_SENDER_NAMESPACE"), os.Getenv("ORBIT_TEST_SENDER_QUEUE"))
s, err := businessStore(db, os.Getenv("ORBIT_TEST_SENDER_NAMESPACE"), os.Getenv("ORBIT_TEST_SENDER_QUEUE"))
if err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,76 @@
package outbox
import (
"context"
"errors"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
var ErrShutdownTimeout = errors.New("outbox sender shutdown deadline exceeded; reconcile persisted records")
// RunOptions 控制进程轮询与关闭,不改变每条记录持久化的 24 小时/100 次预算。
type RunOptions struct {
PollInterval time.Duration // PollInterval 空闲/退避轮询间隔,默认500毫秒,范围10毫秒至10秒。
ShutdownTimeout time.Duration // ShutdownTimeout 取消后等待当前发送及本地确认,默认20秒,最多5分钟。
}
// Run 持续发送;取消后停止领取,限时完成当前交换及确认,调用方必须等待返回。
// 存储/确认错误返回给宿主处理;HTTP暂时失败持久化退避,REVIEW留在数据库供核查。
// 可以多进程并行运行;数据库租约协调领取,同一记录的HTTP仍可能重复,始终复用原键。
func (s *Store) Run(parent context.Context, api *orbit.Client, options RunOptions) error {
if api == nil {
return ErrInvalid
}
if options.PollInterval == 0 {
options.PollInterval = 500 * time.Millisecond
}
if options.ShutdownTimeout == 0 {
options.ShutdownTimeout = 20 * time.Second
}
if options.PollInterval < 10*time.Millisecond || options.PollInterval > 10*time.Second || options.ShutdownTimeout < time.Millisecond || options.ShutdownTimeout > 5*time.Minute {
return ErrInvalid
}
work, cancel := context.WithCancel(context.WithoutCancel(parent))
done := make(chan struct{})
expired := make(chan struct{})
go func() {
defer close(done)
select {
case <-work.Done():
return
case <-parent.Done():
}
timer := time.NewTimer(options.ShutdownTimeout)
defer timer.Stop()
select {
case <-work.Done():
case <-timer.C:
close(expired)
cancel()
}
}()
defer func() { cancel(); <-done }()
for parent.Err() == nil {
_, err := s.SendOnce(work, api)
select {
case <-expired:
return ErrShutdownTimeout
default:
}
if err != nil && !errors.Is(err, ErrIdle) && !errors.Is(err, ErrLeaseLost) {
return err
}
if err == nil {
continue
}
timer := time.NewTimer(options.PollInterval)
select {
case <-parent.Done():
timer.Stop()
case <-timer.C:
}
}
return nil
}

View File

@@ -0,0 +1,32 @@
-- 业务数据库的初始Outbox表,保留原示例表名以兼容,不属于平台迁移,无外键或级联删除。
CREATE TABLE sender_outbox (
namespace_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定目标命名空间,重启及换凭据不得改变',
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定目标队列,发送前核对当前授权及命名空间',
operation_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '业务操作标识,同命名空间内唯一,调用方持久化后复用',
idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '冻结平台提交键,由业务操作标识派生,禁止换键补发',
request_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '冻结任务提交正文,保留精确数字,不存认证凭据',
request_digest BINARY(32) NOT NULL COMMENT '冻结正文SHA256,防止同身份不同内容,非平台摘要算法',
state VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING待发,SENDING领取中,ACCEPTED已受理,REVIEW需核查',
send_count INT UNSIGNED NOT NULL COMMENT '领取次数,包含领取后崩溃的次数,最多100',
lease_epoch BIGINT UNSIGNED NOT NULL COMMENT '领取代次递增,旧发送器不能覆盖新代次结果',
wake_at DATETIME(6) NULL COMMENT '待发下次时间或发送租约期限,终结后为空,UTC微秒',
send_deadline_at DATETIME(6) NOT NULL COMMENT '首次入库24小时后的发送截止,短于平台幂等保留窗口',
last_code VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '最近稳定分类,不保存服务端错误正文或秘密',
task_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '已确认的平台任务标识,只在ACCEPTED非空',
receipt_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '原受理回执,仅ACCEPTED非空,不代表当前执行结果',
created_at DATETIME(6) NOT NULL COMMENT '业务事务首次创建命令UTC时间,微秒精度',
updated_at DATETIME(6) NOT NULL COMMENT '本地状态最近变化UTC时间,微秒精度',
PRIMARY KEY (namespace_id,operation_id),
UNIQUE KEY uq_sender_identity(namespace_id,idempotency_key),
KEY ix_sender_due(namespace_id,queue_id,wake_at,operation_id),
CONSTRAINT ck_sender_document CHECK (JSON_VALID(request_json)),
CONSTRAINT ck_sender_receipt CHECK (receipt_json IS NULL OR JSON_VALID(receipt_json)),
CONSTRAINT ck_sender_count CHECK (send_count<=100 AND lease_epoch=send_count),
CONSTRAINT ck_sender_state CHECK (
(state IN ('PENDING','SENDING') AND wake_at IS NOT NULL AND task_id IS NULL AND receipt_json IS NULL)
OR (state='REVIEW' AND wake_at IS NULL AND task_id IS NULL AND receipt_json IS NULL)
OR (state='ACCEPTED' AND wake_at IS NULL AND task_id IS NOT NULL AND receipt_json IS NOT NULL)
),
CONSTRAINT ck_sender_lease CHECK (state<>'SENDING' OR lease_epoch>0),
CONSTRAINT ck_sender_deadline CHECK (send_deadline_at>created_at AND send_deadline_at<=TIMESTAMPADD(HOUR,24,created_at))
) ENGINE=InnoDB COMMENT='业务事务Outbox,持久化原请求与有限重发状态,不承担业务恰好一次';

View File

@@ -0,0 +1,311 @@
// Package outbox 将待发任务加入调用方的 MySQL 事务,并通过有限租约发送器恢复提交。
package outbox
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"unicode/utf8"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"github.com/go-sql-driver/mysql"
)
//go:embed schema.sql
var schema string
var (
ErrInvalid = errors.New("outbox scope, operation or request is invalid")
ErrConflict = errors.New("outbox operation already has different content; keep the original identity")
ErrStorage = errors.New("outbox storage failed; reconcile the original operation")
ErrCommitUnconfirmed = errors.New("outbox commit is unconfirmed; reconcile the original operation")
ErrIdle = errors.New("no due outbox record")
ErrNotFound = errors.New("outbox operation not found in this scope")
ErrAcceptance = errors.New("outbox acceptance hook failed; confirmation rolled back")
ErrLeaseLost = errors.New("outbox lease was replaced; do not overwrite its result")
idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
)
const maxSends = 100
const recordColumns = `namespace_id,queue_id,operation_id,idempotency_key,request_json,request_digest,state,send_count,lease_epoch,wake_at,send_deadline_at,last_code,task_id,receipt_json,created_at,updated_at`
// Store 固定一个业务数据库及目标范围,不拥有连接池的关闭权。
type Store struct {
db *sql.DB
namespace, queue string
onAccepted AcceptedHook
}
// Open 仅连接业务方数据库,不创建表,不输出原始DSN或数据库错误。
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil || cfg.DBName == "" {
return nil, ErrInvalid
}
cfg.ParseTime = true
cfg.Loc = time.UTC
cfg.MultiStatements = false
cfg.AllowAllFiles = false
cfg.Timeout, cfg.ReadTimeout, cfg.WriteTimeout = 5*time.Second, 8*time.Second, 8*time.Second
if cfg.Params == nil {
cfg.Params = map[string]string{}
}
cfg.Params["time_zone"] = "'+00:00'"
connector, err := mysql.NewConnector(cfg)
if err != nil {
return nil, ErrStorage
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(5 * time.Minute)
if err = db.PingContext(ctx); err != nil {
db.Close()
return nil, ErrStorage
}
return db, nil
}
// SchemaSQL 返回首版无外键表结构,供业务迁移工具管理;不会自动执行。
func SchemaSQL() string { return schema }
// Init 显式创建适配表,已有表会报错;生产升级由业务迁移工具管理。
func Init(ctx context.Context, db *sql.DB) error {
for _, statement := range strings.Split(schema, ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err := db.ExecContext(ctx, statement); err != nil {
return ErrStorage
}
}
return nil
}
// New 构造可并发复用的适配器,不执行建表或网络请求。
func New(db *sql.DB, namespace, queue string, options Options) (*Store, error) {
if db == nil || !idPattern.MatchString(namespace) || !idPattern.MatchString(queue) {
return nil, ErrInvalid
}
return &Store{db: db, namespace: namespace, queue: queue, onAccepted: options.OnAccepted}, nil
}
// document 冻结当前v1默认值及UTC表示;Outbox按文档字节去重,不冒充平台语义摘要。
func (s *Store) document(in orbit.SubmitTaskRequest) ([]byte, error) {
// encoding/json会把非法UTF-8替换成U+FFFD;在冻结摘要前拒绝,不能静默改变业务标识。
for _, value := range []string{in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, in.BusinessKey, in.ConcurrencyKey} {
if !utf8.ValidString(value) {
return nil, ErrInvalid
}
}
if in.QueueID != s.queue || in.Handler == "" || in.HandlerVersion == "" || in.PayloadVersion == "" || len(in.Payload) > 256<<10 || !utf8.Valid(in.Payload) || !json.Valid(in.Payload) || bytes.Equal(bytes.TrimSpace(in.Payload), []byte("null")) {
return nil, ErrInvalid
}
if in.TaskTimeoutMS == 0 {
in.TaskTimeoutMS = 86400000
}
if in.RecoverySafety == "" {
in.RecoverySafety = orbit.RecoveryReconcileOnly
}
if in.Retry.MaxAttempts == 0 {
in.Retry.MaxAttempts = 1
}
if in.Retry.AttemptTimeoutMS == 0 {
in.Retry.AttemptTimeoutMS = 30000
}
if in.Retry.MaxAttempts > 1 {
if in.Retry.InitialDelayMS == 0 {
in.Retry.InitialDelayMS = 1000
}
if in.Retry.MaxDelayMS == 0 {
in.Retry.MaxDelayMS = 30000
}
}
if in.AvailableAt != nil {
at := in.AvailableAt.UTC()
in.AvailableAt = &at
}
if in.StartDeadlineAt != nil {
at := in.StartDeadlineAt.UTC()
in.StartDeadlineAt = &at
}
raw, err := json.Marshal(in)
if err != nil || len(raw) > 512<<10 {
return nil, ErrInvalid
}
return raw, nil
}
// EnqueueTx 只写入调用方事务,不开启/提交/回滚事务,不进行 HTTP 请求。
// tx 必须来自 New 使用的同一业务数据库。失败时调用方必须回滚整个事务。
// duplicate 只表示事务内已有同身份同内容记录,调用方仍必须确认 Commit 成功。
func (s *Store) EnqueueTx(ctx context.Context, tx *sql.Tx, operationID string, in orbit.SubmitTaskRequest) (duplicate bool, err error) {
if tx == nil || !idPattern.MatchString(operationID) {
return false, ErrInvalid
}
raw, err := s.document(in)
if err != nil {
return false, err
}
digest := sha256.Sum256(raw)
_, err = tx.ExecContext(ctx, `INSERT INTO sender_outbox(namespace_id,queue_id,operation_id,idempotency_key,request_json,request_digest,state,send_count,lease_epoch,wake_at,send_deadline_at,last_code,created_at,updated_at) VALUES(?,?,?,?,?,?,'PENDING',0,0,UTC_TIMESTAMP(6),TIMESTAMPADD(HOUR,24,UTC_TIMESTAMP(6)),'',UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, s.namespace, s.queue, operationID, "outbox-task-"+operationID, raw, digest[:])
if err == nil {
return false, nil
}
var duplicateKey *mysql.MySQLError
if !errors.As(err, &duplicateKey) || duplicateKey.Number != 1062 {
return false, ErrStorage
}
var saved []byte
var queue, key string
// 共享锁当前读:核对 RR 旧快照外的竞争提交,且不把重复 INSERT 的共享锁升级为排它锁。
err = tx.QueryRowContext(ctx, `SELECT request_digest,queue_id,idempotency_key FROM sender_outbox WHERE namespace_id=? AND operation_id=? FOR SHARE`, s.namespace, operationID).Scan(&saved, &queue, &key)
if err != nil {
return false, ErrStorage
}
if queue != s.queue || key != "outbox-task-"+operationID || !bytes.Equal(saved, digest[:]) {
return false, ErrConflict
}
return true, nil
}
type scanner interface{ Scan(...any) error }
func scanRecord(row scanner, r *Record, extra ...any) error {
args := []any{&r.NamespaceID, &r.QueueID, &r.OperationID, &r.IdempotencyKey, &r.RequestJSON, &r.RequestDigest, &r.State, &r.SendCount, &r.LeaseEpoch, &r.WakeAt, &r.SendDeadlineAt, &r.LastCode, &r.TaskID, &r.ReceiptJSON, &r.CreatedAt, &r.UpdatedAt}
return row.Scan(append(args, extra...)...)
}
// Status 读取固定命名空间和队列内的本地发送记录,不查询平台执行结果。
func (s *Store) Status(parent context.Context, id string) (Record, error) {
if !idPattern.MatchString(id) {
return Record{}, ErrInvalid
}
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
var r Record
err := scanRecord(s.db.QueryRowContext(ctx, `SELECT `+recordColumns+` FROM sender_outbox WHERE namespace_id=? AND queue_id=? AND operation_id=?`, s.namespace, s.queue, id), &r)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, ErrStorage
}
return r, nil
}
func (s *Store) claim(parent context.Context) (Record, error) {
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return Record{}, ErrStorage
}
defer tx.Rollback()
var r Record
var now time.Time
err = scanRecord(tx.QueryRowContext(ctx, `SELECT `+recordColumns+`,UTC_TIMESTAMP(6) FROM sender_outbox FORCE INDEX(ix_sender_due) WHERE namespace_id=? AND queue_id=? AND wake_at<=UTC_TIMESTAMP(6) ORDER BY wake_at,operation_id LIMIT 1 FOR UPDATE SKIP LOCKED`, s.namespace, s.queue), &r, &now)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrIdle
}
if err != nil {
return Record{}, ErrStorage
}
if r.State != "PENDING" && r.State != "SENDING" {
return Record{}, ErrStorage
}
if !now.Before(r.SendDeadlineAt) || r.SendCount >= maxSends {
r.State = "REVIEW"
r.WakeAt = nil
r.LastCode = "SEND_BUDGET_EXHAUSTED"
if !now.Before(r.SendDeadlineAt) {
r.LastCode = "SEND_DEADLINE"
}
} else {
r.State = "SENDING"
r.SendCount++
r.LeaseEpoch++
wake := now.Add(30 * time.Second)
if wake.After(r.SendDeadlineAt) {
wake = r.SendDeadlineAt
}
r.WakeAt = &wake
}
_, err = tx.ExecContext(ctx, `UPDATE sender_outbox SET state=?,send_count=?,lease_epoch=?,wake_at=?,last_code=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, r.State, r.SendCount, r.LeaseEpoch, r.WakeAt, r.LastCode, s.namespace, r.OperationID)
if err != nil {
return Record{}, ErrStorage
}
if tx.Commit() != nil {
return Record{}, ErrCommitUnconfirmed
}
return r, nil
}
// finish 只接受最近代次;过期但尚未被替代的成功回执可落库,重复HTTP只复用原键。
func (s *Store) finish(parent context.Context, claim Record, state, code string, delay time.Duration, receipt *orbit.SubmitTaskResult) error {
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return ErrStorage
}
defer tx.Rollback()
var current Record
var now time.Time
err = scanRecord(tx.QueryRowContext(ctx, `SELECT `+recordColumns+`,UTC_TIMESTAMP(6) FROM sender_outbox WHERE namespace_id=? AND queue_id=? AND operation_id=? FOR UPDATE`, s.namespace, s.queue, claim.OperationID), &current, &now)
if err != nil {
return ErrStorage
}
if current.State != "SENDING" || current.LeaseEpoch != claim.LeaseEpoch {
return ErrLeaseLost
}
var wake *time.Time
var task *string
var raw []byte
if state == "ACCEPTED" {
if receipt == nil || !validReceipt(current, *receipt) {
return ErrInvalid
}
raw, err = json.Marshal(receipt)
if err != nil || len(raw) > 1<<20 {
return ErrInvalid
}
task = &receipt.Task.ID
// 持锁顺序:Outbox → 业务行。回调只能做短 SQL,不得提交、回滚或执行网络副作用。
if s.onAccepted != nil {
if err = s.onAccepted(ctx, tx, current, *receipt); err != nil {
return ErrAcceptance
}
}
} else if state == "PENDING" {
if delay < time.Second || delay > time.Hour {
return ErrInvalid
}
if !now.Before(current.SendDeadlineAt) || current.SendCount >= maxSends {
state = "REVIEW"
code = "SEND_BUDGET_EXHAUSTED"
} else {
next := now.Add(delay)
if next.After(current.SendDeadlineAt) {
next = current.SendDeadlineAt
}
wake = &next
}
} else if state != "REVIEW" {
return ErrInvalid
}
if _, err = tx.ExecContext(ctx, `UPDATE sender_outbox SET state=?,last_code=?,wake_at=?,task_id=?,receipt_json=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, state, code, wake, task, raw, s.namespace, current.OperationID); err != nil {
return ErrStorage
}
if tx.Commit() != nil {
return ErrCommitUnconfirmed
}
return nil
}

View File

@@ -40,7 +40,7 @@ func integrationDB(t *testing.T) (*sql.DB, string) {
}
func testStore(t *testing.T, db *sql.DB) *Store {
t.Helper()
s, err := New(db, testID(t), testID(t))
s, err := businessStore(db, testID(t), testID(t))
if err != nil {
t.Fatal(err)
}
@@ -173,7 +173,7 @@ func TestIntegrationSenderConcurrentEnqueueAndConflict(t *testing.T) {
if _, err := s.Enqueue(context.Background(), id, "objects/test", in); !errors.Is(err, ErrConflict) {
t.Fatal("request content conflict lost", err)
}
other, _ := New(db, s.namespace, testID(t))
other, _ := businessStore(db, s.namespace, testID(t))
if _, err := other.Status(context.Background(), id); !errors.Is(err, ErrNotFound) {
t.Fatal("cross queue record returned", err)
}
@@ -209,7 +209,7 @@ func TestIntegrationSenderBusinessRollbackAndConfirmationAtomicity(t *testing.T)
}
receipt := fakeReceipt(t, s)
execSQL(t, db, `CREATE TRIGGER sender_update_fail BEFORE UPDATE ON sender_jobs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='injected business confirmation failure'`)
if err = s.finish(context.Background(), claim, "ACCEPTED", "ACCEPTED", 0, &receipt); !errors.Is(err, ErrStorage) {
if err = s.finish(context.Background(), claim, "ACCEPTED", "ACCEPTED", 0, &receipt); !errors.Is(err, ErrAcceptance) {
t.Fatal(err)
}
if r := status(t, s, id); r.State != "SENDING" || r.TaskID != nil {

View File

@@ -0,0 +1,179 @@
package outbox
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func TestIntegrationCallerOwnedTransaction(t *testing.T) {
db, _ := integrationDB(t)
s, err := New(db, testID(t), testID(t), Options{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
id := testID(t)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
dup, err := s.EnqueueTx(ctx, tx, id, testRequest(s.queue))
if err != nil || dup {
t.Fatal(dup, err)
}
if dup, err = s.EnqueueTx(ctx, tx, id, testRequest(s.queue)); err != nil || !dup {
t.Fatal("same transaction duplicate not recognized", dup, err)
}
// 从另一连接看不到未提交的待发任务;包不得偷偷提交,也不得发起HTTP。
if _, err = s.Status(ctx, id); !errors.Is(err, ErrNotFound) {
t.Fatal("adapter committed caller transaction", err)
}
if _, err = s.claim(ctx); !errors.Is(err, ErrIdle) {
t.Fatal("uncommitted task was claimable", err)
}
if err = tx.Rollback(); err != nil {
t.Fatal(err)
}
if _, err = s.Status(ctx, id); !errors.Is(err, ErrNotFound) {
t.Fatal("caller rollback lost", err)
}
// 先在RR事务建立旧快照,再由另一事务提交相同身份,重复核对必须是当前读。
tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
var n int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sender_outbox WHERE namespace_id=?`, s.namespace).Scan(&n); err != nil {
t.Fatal(err)
}
other, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer other.Rollback()
if _, err = s.EnqueueTx(ctx, other, id, testRequest(s.queue)); err != nil {
t.Fatal(err)
}
if err = other.Commit(); err != nil {
t.Fatal(err)
}
if dup, err = s.EnqueueTx(ctx, tx, id, testRequest(s.queue)); err != nil || !dup {
t.Fatal("RR current read failed", dup, err)
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
claim, err := s.claim(ctx)
if err != nil {
t.Fatal(err)
}
receipt := fakeReceipt(t, s)
if err = s.finish(ctx, claim, "ACCEPTED", "ACCEPTED", 0, &receipt); err != nil {
t.Fatal("optional hook requires business schema", err)
}
if status(t, s, id).State != "ACCEPTED" {
t.Fatal("missing acceptance")
}
}
func TestIntegrationSenderRuntimeDrain(t *testing.T) {
for _, timeout := range []bool{false, true} {
t.Run(map[bool]string{false: "finish_inflight", true: "bounded_timeout"}[timeout], func(t *testing.T) {
db, _ := integrationDB(t)
s, err := New(db, testID(t), testID(t), Options{})
if err != nil {
t.Fatal(err)
}
id := testID(t)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if _, err = s.EnqueueTx(context.Background(), tx, id, testRequest(s.queue)); err != nil {
t.Fatal(err)
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
release := make(chan struct{})
unblock := sync.OnceFunc(func() { close(release) })
receipt := fakeReceipt(t, s)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
writeTestJSON(w, map[string]any{"request_id": id, "data": orbit.Queue{ID: s.queue, NamespaceID: s.namespace}})
return
}
close(entered)
select {
case <-release:
case <-r.Context().Done():
return
}
w.WriteHeader(201)
writeTestJSON(w, map[string]any{"request_id": id, "data": receipt})
}))
defer server.Close()
defer unblock()
api, err := orbit.NewClient(server.URL, orbit.Options{Token: "test"})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
grace := time.Second
if timeout {
grace = 20 * time.Millisecond
}
done := make(chan error, 1)
go func() { done <- s.Run(ctx, api, RunOptions{ShutdownTimeout: grace}) }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("sender did not enter HTTP")
}
cancel()
if !timeout {
select {
case err := <-done:
t.Fatal("did not drain", err)
case <-time.After(25 * time.Millisecond):
}
unblock()
}
select {
case err = <-done:
case <-time.After(5 * time.Second):
t.Fatal("shutdown hung")
}
if timeout {
if !errors.Is(err, ErrShutdownTimeout) {
t.Fatal(err)
}
if status(t, s, id).State == "ACCEPTED" {
t.Fatal("timeout fabricated acceptance")
}
} else {
if err != nil || status(t, s, id).State != "ACCEPTED" {
t.Fatal(err)
}
}
})
}
}
func writeTestJSON(w http.ResponseWriter, v any) { _ = json.NewEncoder(w).Encode(v) }

View File

@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
@@ -26,13 +27,15 @@ func main() {
}
func run() error {
if len(os.Args) < 2 {
return errors.New("usage: orbit-admin release manifest|bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply")
}
switch os.Args[1] {
case "release":
return runRelease(os.Args[2:])
case "bootstrap":
return runBootstrap(os.Args[2:])
case "console-user":
return runConsoleUser(os.Args[2:])
case "verify":
return runVerify(os.Args[2:])
case "observe":
@@ -42,7 +45,7 @@ func run() error {
case "credential":
return runCredential(os.Args[2:])
}
return errors.New("usage: orbit-admin release manifest|bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply")
}
// releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。
@@ -205,8 +208,16 @@ func runObserve(args []string) error {
}
func runRetention(args []string) error {
if len(args) > 0 {
switch args[0] {
case "archive":
return runRetentionArchive(args[1:])
case "verify-archive":
return runRetentionVerifyArchive(args[1:])
}
}
if len(args) == 0 || args[0] != "inventory" {
return errors.New("usage: orbit-admin retention inventory --before RFC3339")
return errors.New("usage: orbit-admin retention inventory --before RFC3339 | archive | verify-archive")
}
flags := flag.NewFlagSet("retention inventory", flag.ContinueOnError)
var before string
@@ -255,6 +266,11 @@ func runBootstrap(args []string) error {
flags.StringVar(&in.Environment, "environment", "", "environment name")
flags.StringVar(&in.Queue, "queue", "", "initial queue name")
flags.StringVar(&output, "output", "", "new private credentials JSON file")
var consoleUsername, consoleDisplayName, consolePassword string
var consolePasswordStdin bool
flags.StringVar(&consoleUsername, "console-username", "", "optional first console administrator username")
flags.StringVar(&consoleDisplayName, "console-display-name", "", "optional console administrator display name")
flags.BoolVar(&consolePasswordStdin, "console-password-stdin", false, "read the first console administrator password from stdin")
flags.BoolVar(&in.WorkerAccess, "worker-access", false, "also grant Worker connect and consume actions for development")
flags.BoolVar(&in.ScheduleAccess, "schedule-access", false, "also grant schedule read and manage actions for development")
flags.BoolVar(&in.EventAccess, "event-access", false, "also grant result subscription read, manage and consume actions")
@@ -271,6 +287,20 @@ func runBootstrap(args []string) error {
if flags.NArg() != 0 || output == "" {
return errors.New("--output and named resource arguments are required")
}
if consolePasswordStdin {
raw, err := io.ReadAll(io.LimitReader(os.Stdin, 1025))
if err != nil {
return errors.New("console administrator password could not be read")
}
consolePassword = strings.TrimRight(string(raw), "\r\n")
}
if consoleUsername == "" && (consolePasswordStdin || consoleDisplayName != "") {
return errors.New("--console-username is required with console administrator options")
}
if consoleUsername != "" && !consolePasswordStdin {
return errors.New("--console-password-stdin is required when creating the first console administrator")
}
in.ConsoleUsername, in.ConsoleDisplayName, in.ConsolePassword = consoleUsername, consoleDisplayName, consolePassword
cfg, err := config.Load()
if err != nil {
return err
@@ -324,3 +354,78 @@ func runBootstrap(args []string) error {
fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.")
return nil
}
// runConsoleUser 创建一个控制台登录账号。密码默认从标准输入读取,避免出现在
// shell 历史和进程列表;账号只绑定已有业务凭据,不会生成或输出业务 token。
func runConsoleUser(args []string) error {
if len(args) == 0 || args[0] != "create" {
return errors.New("usage: orbit-admin console-user create --credential-file path --username name [--display-name name] [--password-stdin]")
}
flags := flag.NewFlagSet("console-user create", flag.ContinueOnError)
var namespace, credential, credentialFile, username, displayName, password string
var passwordStdin bool
flags.StringVar(&namespace, "namespace-id", "", "namespace id; can be read from --credential-file")
flags.StringVar(&credential, "credential-id", "", "credential id; can be read from --credential-file")
flags.StringVar(&credentialFile, "credential-file", "", "private bootstrap credential JSON file")
flags.StringVar(&username, "username", "", "console login username")
flags.StringVar(&displayName, "display-name", "", "console display name")
flags.StringVar(&password, "password", "", "password; prefer --password-stdin")
flags.BoolVar(&passwordStdin, "password-stdin", false, "read password from stdin")
if err := flags.Parse(args[1:]); err != nil {
return err
}
if flags.NArg() != 0 || username == "" {
return errors.New("--username and principal information are required")
}
if credentialFile != "" {
raw, err := os.ReadFile(credentialFile)
if err != nil {
return errors.New("credential file could not be read")
}
var file struct {
NamespaceID core.ID `json:"namespace_id"`
CredentialID core.ID `json:"credential_id"`
}
if err := json.Unmarshal(raw, &file); err != nil {
return errors.New("credential file is invalid")
}
if namespace == "" {
namespace = string(file.NamespaceID)
}
if credential == "" {
credential = string(file.CredentialID)
}
}
if passwordStdin {
raw, err := io.ReadAll(io.LimitReader(os.Stdin, 2049))
if err != nil {
return errors.New("password could not be read")
}
password = strings.TrimRight(string(raw), "\r\n")
}
ns, cred := core.ID(namespace), core.ID(credential)
if ns.Validate() != nil || cred.Validate() != nil || password == "" {
return errors.New("valid namespace, credential and password are required")
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return errors.New("database unavailable")
}
defer db.Close()
user, err := mysql.NewConsoleAuthStore(db).CreateConsoleUser(ctx, mysql.ConsoleUserInput{NamespaceID: ns, CredentialID: cred, Username: username, DisplayName: displayName, Password: password})
if err != nil {
return err
}
encoded, err := json.Marshal(user)
if err != nil {
return err
}
fmt.Println(string(encoded))
return nil
}

View File

@@ -0,0 +1,108 @@
package main
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/config"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func runRetentionArchive(args []string) error {
flags := flag.NewFlagSet("retention archive", flag.ContinueOnError)
var spec retention.JobSpec
var directory, namespace string
var options retention.JobOptions
var timeout time.Duration
flags.StringVar(&directory, "directory", os.Getenv("ORBIT_ARCHIVE_DIRECTORY"), "private per-job output directory; same directory resumes saved batches")
flags.StringVar(&namespace, "namespace", "", "namespace ID to archive")
flags.StringVar(&spec.Kind, "kind", "", "tasks|workflows|events|audit|deliveries|inbox|idempotency")
flags.Uint64Var(&spec.PolicyRevision, "revision", 0, "confirmed database retention policy revision")
flags.IntVar(&spec.BatchSize, "batch-size", 20, "roots per batch, 1-100; immutable for this job")
flags.IntVar(&options.MaxBatches, "max-batches", 10, "maximum new batches per invocation, 1-1000")
flags.Int64Var(&options.MaxBytes, "max-bytes", 256<<20, "total saved manifest and batch bytes, 1 MiB-100 GiB")
flags.DurationVar(&timeout, "timeout", 5*time.Minute, "invocation deadline, 1s-1h")
if err := flags.Parse(args); err != nil {
return err
}
spec.NamespaceID = core.ID(namespace)
if flags.NArg() != 0 || directory == "" || !filepath.IsAbs(directory) || timeout < time.Second || timeout > time.Hour || options.MaxBatches < 1 || options.MaxBatches > 1000 || options.MaxBytes < 1<<20 || options.MaxBytes > 100<<30 {
return errors.New("invalid archive arguments; use --help for limits and an absolute --directory")
}
if err := spec.Validate(); err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(signalCtx, timeout)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return errors.New("database unavailable")
}
defer db.Close()
if err := mysql.CheckSchema(ctx, db); err != nil {
return errors.New("database schema is not ready")
}
store := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults)
status, runErr := retention.RunArchive(ctx, directory, spec, options, func(ctx context.Context, job core.ID, input retention.ArchiveInput) (retention.ArchiveSnapshot, error) {
snapshot, err := store.ExportRetentionForJob(ctx, spec.NamespaceID, job, input)
if err != nil {
// 数据库原始错误可能含 SQL 参数,只输出固定错误码,业务正文不进入控制台日志。
if ctx.Err() != nil {
return retention.ArchiveSnapshot{}, ctx.Err()
}
return retention.ArchiveSnapshot{}, fmt.Errorf("archive database export failed (%s); keep original directory and verify policy before retrying", fault.Code(err))
}
return snapshot, nil
})
if err := json.NewEncoder(os.Stdout).Encode(status); err != nil {
return err
}
return runErr
}
func runRetentionVerifyArchive(args []string) error {
flags := flag.NewFlagSet("retention verify-archive", flag.ContinueOnError)
var directory, expectedSHA string
flags.StringVar(&directory, "directory", os.Getenv("ORBIT_ARCHIVE_DIRECTORY"), "saved archive job directory; no database connection required")
flags.StringVar(&expectedSHA, "expected-sha256", "", "optional independently saved last_sha256 to pin the entire chain")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 || directory == "" {
return errors.New("--directory is required")
}
if expectedSHA != "" {
raw, err := hex.DecodeString(expectedSHA)
if err != nil || len(raw) != 32 || hex.EncodeToString(raw) != expectedSHA {
return errors.New("--expected-sha256 must be 64 lowercase hexadecimal characters")
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
status, err := retention.VerifyArchive(ctx, directory)
if encodeErr := json.NewEncoder(os.Stdout).Encode(status); encodeErr != nil {
return encodeErr
}
if err == nil && expectedSHA != "" && status.LastSHA256 != expectedSHA {
return errors.New("archive differs from independently saved digest")
}
return err
}

View File

@@ -0,0 +1,50 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"path/filepath"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func TestRetentionArchiveValidationBeforeDatabase(t *testing.T) {
t.Setenv("ORBIT_MYSQL_DSN", "")
for _, args := range [][]string{
{}, {"--directory", "relative"}, {"--directory", t.TempDir(), "--namespace", "invalid"},
{"--directory", t.TempDir(), "--namespace", "11111111111111111111111111111111", "--kind", "tasks", "--revision", "1", "--max-batches", "0"},
} {
if err := runRetentionArchive(args); err == nil {
t.Fatalf("invalid args accepted: %v", args)
}
}
}
func TestVerifyArchiveOfflinePinsDigest(t *testing.T) {
t.Setenv("ORBIT_MYSQL_DSN", "")
dir := filepath.Join(t.TempDir(), "job")
ns := core.ID("11111111111111111111111111111111")
spec := retention.JobSpec{NamespaceID: ns, Kind: "tasks", PolicyRevision: 1, BatchSize: 1}
status, err := retention.RunArchive(context.Background(), dir, spec, retention.JobOptions{MaxBatches: 1, MaxBytes: 1 << 20}, func(ctx context.Context, id core.ID, input retention.ArchiveInput) (retention.ArchiveSnapshot, error) {
policy := retention.Defaults(ns)
policy.Enabled = true
policy.Revision = 1
raw, err := json.Marshal(map[string]any{"policy": policy, "kind": "tasks", "cutoff": time.Now().UTC(), "root_count": 0, "source_retained": true, "next_cursor": ""})
digest := sha256.Sum256(raw)
return retention.ArchiveSnapshot{FormatVersion: 1, SHA256: hex.EncodeToString(digest[:]), Content: string(raw)}, err
})
if err != nil {
t.Fatal(err)
}
if err := runRetentionVerifyArchive([]string{"--directory", dir, "--expected-sha256", status.LastSHA256}); err != nil {
t.Fatal(err)
}
if err := runRetentionVerifyArchive([]string{"--directory", dir, "--expected-sha256", "0000000000000000000000000000000000000000000000000000000000000000"}); err == nil {
t.Fatal("trusted digest mismatch accepted")
}
}

View File

@@ -13,6 +13,7 @@ import (
"git.yuntuo.hk/k2safe/orbit/internal/config"
"git.yuntuo.hk/k2safe/orbit/internal/observability"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
core "git.yuntuo.hk/k2safe/orbit/model"
@@ -84,7 +85,9 @@ func run() error {
txStats := mysql.ReadTransactionStats()
metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted})
}
server := httpserver.New(cfg.HTTPAddr, readiness.Check, service.New(mysql.NewTaskStore(db)))
consoleAuth := mysql.NewConsoleAuthStore(db)
retentionStore := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults)
server := httpserver.NewWithConsoleAuth(cfg.HTTPAddr, readiness.Check, consoleAuth, service.New(mysql.NewTaskStore(db)))
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
node, err := core.NewID()
if err != nil {
@@ -120,7 +123,38 @@ func run() error {
httpserver.RegisterSchedules(server, schedule.New(schedules))
httpserver.RegisterWorkflowDefinitions(server, definition.New(mysql.NewWorkflowDefinitionStore(db)))
httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db)))
httpserver.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore)
httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID)
httpserver.WrapConsoleAuth(server, consoleAuth)
httpserver.WrapMetrics(server, metrics)
if cfg.ArchiveRoot != "" {
archives := &retention.ArchiveRuntime{Directory: cfg.ArchiveRoot, StorageID: cfg.ArchiveStorageID, MaxBytes: cfg.ArchiveMaxBytes, Store: retentionStore}
if err := archives.Init(); err != nil {
return errors.New("archive volume initialization failed; check path, ownership and storage identity")
}
archiveCtx, stopArchives := context.WithCancel(root)
archiveDone := make(chan struct{})
go func() {
defer close(archiveDone)
ticker := time.NewTicker(cfg.ArchiveTickInterval)
defer ticker.Stop()
for {
select {
case <-archiveCtx.Done():
return
case <-ticker.C:
call, cancel := context.WithTimeout(archiveCtx, 45*time.Second)
err := archives.Tick(call)
cancel()
if err != nil && archiveCtx.Err() == nil {
slog.Error("archive batch could not be confirmed", "node_id", node)
}
}
}
}()
defer func() { stopArchives(); <-archiveDone }()
}
workflows, err := mysql.NewWorkflowRuntime(db, node)
if err != nil {
return err

View File

@@ -0,0 +1,58 @@
# 与 deploy/docker-compose.yml 一起使用:
# docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.ha.yml up -d mysql verify orbit-a orbit-b
# 默认不启动 base 文件中的 orbit 单节点服务,避免重复实例。
x-orbit-environment: &orbit-environment
ORBIT_HTTP_ADDR: 0.0.0.0:8080
ORBIT_MYSQL_DSN: "${ORBIT_DB_USER}:${ORBIT_DB_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME:-orbit}?parseTime=true&multiStatements=true"
ORBIT_DB_MAX_OPEN: ${ORBIT_DB_MAX_OPEN:-20}
ORBIT_DB_MAX_IDLE: ${ORBIT_DB_MAX_IDLE:-5}
ORBIT_WORKFLOW_TICK_INTERVAL: ${ORBIT_WORKFLOW_TICK_INTERVAL:-1s}
ORBIT_SCHEDULE_TICK_INTERVAL: ${ORBIT_SCHEDULE_TICK_INTERVAL:-1s}
ORBIT_RECOVERY_TICK_INTERVAL: ${ORBIT_RECOVERY_TICK_INTERVAL:-2s}
ORBIT_RUNTIME_BATCH_LIMIT: ${ORBIT_RUNTIME_BATCH_LIMIT:-100}
ORBIT_SHUTDOWN_TIMEOUT: ${ORBIT_SHUTDOWN_TIMEOUT:-10s}
ORBIT_WORKER_CONNECTION_LIMIT: ${ORBIT_WORKER_CONNECTION_LIMIT:-128}
ORBIT_EVENT_CONNECTION_LIMIT: ${ORBIT_EVENT_CONNECTION_LIMIT:-128}
ORBIT_DISPATCH_WORKERS: ${ORBIT_DISPATCH_WORKERS:-4}
ORBIT_DISPATCH_CAPACITY: ${ORBIT_DISPATCH_CAPACITY:-128}
ORBIT_RETENTION_IDEMPOTENCY_DAYS: ${ORBIT_RETENTION_IDEMPOTENCY_DAYS:-90}
ORBIT_RETENTION_TASK_DAYS: ${ORBIT_RETENTION_TASK_DAYS:-365}
ORBIT_RETENTION_WORKFLOW_DAYS: ${ORBIT_RETENTION_WORKFLOW_DAYS:-365}
ORBIT_RETENTION_EVENT_DAYS: ${ORBIT_RETENTION_EVENT_DAYS:-365}
ORBIT_RETENTION_DELIVERY_DAYS: ${ORBIT_RETENTION_DELIVERY_DAYS:-90}
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
services:
orbit:
profiles:
- single
orbit-a:
image: ${ORBIT_IMAGE:-orbit:dev}
restart: unless-stopped
init: true
depends_on:
verify:
condition: service_completed_successfully
environment:
<<: *orbit-environment
ports:
- "${ORBIT_HTTP_BIND:-127.0.0.1}:${ORBIT_HTTP_PORT_A:-8081}:8080"
stop_grace_period: 30s
orbit-b:
image: ${ORBIT_IMAGE:-orbit:dev}
restart: unless-stopped
init: true
depends_on:
verify:
condition: service_completed_successfully
environment:
<<: *orbit-environment
ports:
- "${ORBIT_HTTP_BIND:-127.0.0.1}:${ORBIT_HTTP_PORT_B:-8082}:8080"
stop_grace_period: 30s

102
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,102 @@
name: orbit
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: ${ORBIT_DB_NAME:-orbit}
MYSQL_USER: ${ORBIT_DB_USER:?ORBIT_DB_USER is required}
MYSQL_PASSWORD: ${ORBIT_DB_PASSWORD:?ORBIT_DB_PASSWORD is required}
MYSQL_ROOT_PASSWORD: ${ORBIT_DB_ROOT_PASSWORD:?ORBIT_DB_ROOT_PASSWORD is required}
volumes:
- orbit-mysql:/var/lib/mysql
healthcheck:
test:
- CMD-SHELL
- MYSQL_PWD="$${MYSQL_ROOT_PASSWORD}" mysqladmin ping -h 127.0.0.1 -uroot --silent
interval: 5s
timeout: 5s
retries: 30
start_period: 20s
migrate:
image: ${ORBIT_IMAGE:-orbit:dev}
restart: "no"
depends_on:
mysql:
condition: service_healthy
entrypoint: ["/usr/local/bin/orbit-migrate"]
command: ["up"]
environment:
ORBIT_MYSQL_DSN: "root:${ORBIT_DB_ROOT_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME:-orbit}?parseTime=true&multiStatements=true"
grant:
image: mysql:8.4
restart: "no"
depends_on:
mysql:
condition: service_healthy
entrypoint: ["/bin/sh", "-ec"]
command:
- |
case "$${ORBIT_DB_NAME}" in *[!A-Za-z0-9_]*|'') echo 'ORBIT_DB_NAME must contain only letters, numbers and underscores' >&2; exit 2;; esac
case "$${ORBIT_DB_USER}" in *[!A-Za-z0-9_]*|'') echo 'ORBIT_DB_USER must contain only letters, numbers and underscores' >&2; exit 2;; esac
MYSQL_PWD="$${MYSQL_ROOT_PASSWORD}" mysql --protocol=tcp -h mysql -uroot \
-e "REVOKE ALL PRIVILEGES, GRANT OPTION FROM '$${ORBIT_DB_USER}'@'%'; \
GRANT SELECT, INSERT, UPDATE, DELETE ON \`$${ORBIT_DB_NAME}\`.* TO '$${ORBIT_DB_USER}'@'%'; \
FLUSH PRIVILEGES;"
environment:
MYSQL_ROOT_PASSWORD: ${ORBIT_DB_ROOT_PASSWORD:?ORBIT_DB_ROOT_PASSWORD is required}
ORBIT_DB_NAME: ${ORBIT_DB_NAME:-orbit}
ORBIT_DB_USER: ${ORBIT_DB_USER:?ORBIT_DB_USER is required}
verify:
image: ${ORBIT_IMAGE:-orbit:dev}
restart: "no"
depends_on:
migrate:
condition: service_completed_successfully
grant:
condition: service_completed_successfully
entrypoint: ["/usr/local/bin/orbit-admin"]
command: ["verify"]
environment:
ORBIT_MYSQL_DSN: "${ORBIT_DB_USER}:${ORBIT_DB_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME:-orbit}?parseTime=true&multiStatements=true"
orbit:
image: ${ORBIT_IMAGE:-orbit:dev}
restart: unless-stopped
init: true
depends_on:
verify:
condition: service_completed_successfully
environment:
ORBIT_HTTP_ADDR: 0.0.0.0:8080
ORBIT_MYSQL_DSN: "${ORBIT_DB_USER}:${ORBIT_DB_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME:-orbit}?parseTime=true&multiStatements=true"
ORBIT_DB_MAX_OPEN: ${ORBIT_DB_MAX_OPEN:-20}
ORBIT_DB_MAX_IDLE: ${ORBIT_DB_MAX_IDLE:-5}
ORBIT_WORKFLOW_TICK_INTERVAL: ${ORBIT_WORKFLOW_TICK_INTERVAL:-1s}
ORBIT_SCHEDULE_TICK_INTERVAL: ${ORBIT_SCHEDULE_TICK_INTERVAL:-1s}
ORBIT_RECOVERY_TICK_INTERVAL: ${ORBIT_RECOVERY_TICK_INTERVAL:-2s}
ORBIT_RUNTIME_BATCH_LIMIT: ${ORBIT_RUNTIME_BATCH_LIMIT:-100}
ORBIT_SHUTDOWN_TIMEOUT: ${ORBIT_SHUTDOWN_TIMEOUT:-10s}
ORBIT_WORKER_CONNECTION_LIMIT: ${ORBIT_WORKER_CONNECTION_LIMIT:-128}
ORBIT_EVENT_CONNECTION_LIMIT: ${ORBIT_EVENT_CONNECTION_LIMIT:-128}
ORBIT_DISPATCH_WORKERS: ${ORBIT_DISPATCH_WORKERS:-4}
ORBIT_DISPATCH_CAPACITY: ${ORBIT_DISPATCH_CAPACITY:-128}
ORBIT_RETENTION_IDEMPOTENCY_DAYS: ${ORBIT_RETENTION_IDEMPOTENCY_DAYS:-90}
ORBIT_RETENTION_TASK_DAYS: ${ORBIT_RETENTION_TASK_DAYS:-365}
ORBIT_RETENTION_WORKFLOW_DAYS: ${ORBIT_RETENTION_WORKFLOW_DAYS:-365}
ORBIT_RETENTION_EVENT_DAYS: ${ORBIT_RETENTION_EVENT_DAYS:-365}
ORBIT_RETENTION_DELIVERY_DAYS: ${ORBIT_RETENTION_DELIVERY_DAYS:-90}
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
ports:
- "${ORBIT_HTTP_BIND:-127.0.0.1}:${ORBIT_HTTP_PORT:-8080}:8080"
stop_grace_period: 30s
volumes:
orbit-mysql:

View File

@@ -0,0 +1,122 @@
# Orbit HTTPS/WSS 入口示例。
# 将 orbit.example.com 和证书路径替换成正式值后,放到 Nginx 的 conf.d 目录。
# 本文件位于 http 配置上下文中;map/limit_req_zone 不能放进 server 块。
map $http_upgrade $orbit_connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=orbit_console_login:10m rate=10r/m;
upstream orbit_backend {
server 127.0.0.1:8080;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name orbit.example.com;
# ACME HTTP-01 或其他证书签发工具可使用此目录。
location ^~ /.well-known/acme-challenge/ {
root /var/www/orbit-acme;
default_type text/plain;
}
location / {
return 308 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name orbit.example.com;
ssl_certificate /etc/letsencrypt/live/orbit.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/orbit.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:orbit_tls:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
client_max_body_size 1m;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
# 长连接只用于 Worker 和结果订阅;心跳由协议负责,代理不能自行关闭空闲会话。
location = /v1/worker/connect {
proxy_pass http://orbit_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $orbit_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h;
proxy_buffering off;
}
location = /v1/events/connect {
proxy_pass http://orbit_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $orbit_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h;
proxy_buffering off;
}
# 控制台登录额外限制来源 IP 的尝试速率;业务任务速率仍由 Orbit 的持久化配额和授权决定。
location = /console/auth/login {
limit_req zone=orbit_console_login burst=5 nodelay;
proxy_pass http://orbit_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
location = /health/live {
access_log off;
proxy_pass http://orbit_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 5s;
}
location = /health/ready {
access_log off;
proxy_pass http://orbit_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 5s;
}
location / {
proxy_pass http://orbit_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}

View File

@@ -1,6 +1,6 @@
# 数据模型与实现基线
更新:2026-09-18。数据库统一禁止外键。当前迁移版本为28;000026–000028只增加固定业务观测读取所需索引,不增加实体或字段。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;优先级及节点公平领取已实现;可靠订阅核心、HTTP/WSS、管理与消费 SDK 已实现,独立 Inbox 场景已实现,流程编排继续开发。
更新:2026-09-22。数据库统一禁止外键。当前迁移版本为30;000026–000028只增加固定业务观测读取所需索引,000029增加控制台账号/会话,000030增加命名空间级保留策略。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;优先级及节点公平领取已实现;可靠订阅核心、HTTP/WSS、管理与消费 SDK 已实现,独立 Inbox 场景已实现,流程编排继续开发。
## 1. 模块与依赖
@@ -35,7 +35,7 @@
## 3. 实体清单
当前共 36 个实体:初始迁移包含 30 个,000003 增加进程会话头和迟到证据,000010 增加应用共享额度,000013 增加结果流与不可变结果快照。000024 增加固定流程版本的查询范围。迁移自身另有 `orbit_schema_migrations` 记录。
当前共 39 个业务实体:原运行模型为36个,000029增加控制台账号与会话,000030增加保留策略。迁移自身另有 `orbit_schema_migrations` 记录;控制台和策略表同样遵循零外键、中文 COMMENT、应用层归属校验。
| 模块 | Go 模型 | 数据表 |
| --- | --- | --- |
@@ -50,6 +50,9 @@
| platform | `Subscription` | `orbit_subscriptions` |
| platform | `Delivery` | `orbit_deliveries` |
| platform | `AuditEvent` | `orbit_audit_events` |
| platform | `ConsoleUser` | `orbit_console_users` |
| platform | `ConsoleSession` | `orbit_console_sessions` |
| internal/retention | `Policy` | `orbit_retention_policies` |
| pulse | `Queue` | `pulse_queues` |
| pulse | `ExecutionGate` | `pulse_execution_gates` |
| pulse | `WorkerSession` | `pulse_worker_sessions` |
@@ -126,7 +129,7 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
000007_task_cancellation 允许尝试证据记录 CANCELLED,不新增实体;取消意图、结果和历史不确定效果的规则见 [取消契约](api/cancellation.md)。
000008_no_foreign_keys 移除历史 70 个外键,保留索引、唯一约束、CHECK、触发器和数据。旧迁移已在回归库应用,按不可变校验和约定保留原文件;已有库继续按历史链前向升级。新装库由迁移器使用当前版本的无外键基线直接建表,再登记 1–28 的不可变校验和,因此初始化过程也不会先创建历史外键;基线版本变化时必须随新迁移重新生成并回归。后续迁移禁止再引入外键。升级旧库时先停用旧服务,应用完整迁移再启动新版;迁移需要 DDL 锁,应在目标部署环境演练维护窗口。
000008_no_foreign_keys 移除历史 70 个外键,保留索引、唯一约束、CHECK、触发器和数据。旧迁移已在回归库应用,按不可变校验和约定保留原文件;已有库继续按历史链前向升级。新装库由迁移器使用当前版本的无外键基线直接建表,再登记 1–30 的不可变校验和,因此初始化过程也不会先创建历史外键;基线版本变化时必须随新迁移重新生成并回归。后续迁移禁止再引入外键。升级旧库时先停用旧服务,应用完整迁移再启动新版;迁移需要 DDL 锁,应在目标部署环境演练维护窗口。
000009_queue_resources 增加 Queue.MaxUnfinishedTasks、UnfinishedTasks、StartWindowUntil、StartsInWindow,Go 字段与 SQL COMMENT 均有中文语义,实体仍为 32。未终结任务按 WAITING/LEASED/RUNNING/NEEDS_REVIEW 回填;窗口初始为空、计数为零。运行计数与任务/开始授权同事务维护,不改变队列配置 revision,配置变更不能重置它们。降额允许已有使用量暂时超过新上限,不删除任务或撤销已有许可;详见 [队列契约](api/queues.md) 和 [资源回归](reports/phase-3-queue-resources.md)。
@@ -253,3 +256,21 @@ Event.WorkflowID为只读STORED生成列,从原不可变事件派生流程归
000027为`orbit_events(type,occurred_at,id)`增加固定事件窗口索引。采样器只读取代码列出的任务和流程事件类别、最近5分钟、每类最多10000条;未知事件类型不写入标签,事件指标只说明本节点有限趋势,不能代替审计查询、端到端阶段延迟或跨节点吞吐。索引不改变事件原文、不可变触发器、外键策略或事务边界。
000028为`pulse_tasks(state,completed_at,id)`和`weave_instances(state,completed_at,id)`增加终态耗时样本索引。采样器只读取最近5分钟内成功、失败或取消记录,最多10000条,计算创建到终态的平均/最大秒数并在截断时复用统一标记;不把该样本解释为端到端外部效果或全局SLO。
## 控制台身份与历史保留策略(000029–000030)
000029增加 `orbit_console_users` 和 `orbit_console_sessions`,只保存控制台账号的 bcrypt 哈希、绑定凭据及 HttpOnly 会话摘要;不保存密码或令牌原文。控制台继续单管理员模型,服务层每次修改前核对会话、命名空间和绑定凭据权限。
000030增加 `orbit_retention_policies`,按命名空间保存归档开关、幂等/任务/流程/事件/交付/Inbox/审计窗口、修订号及时间。所有字段都有中文 COMMENT,策略不含外键或级联。数据库策略优先于部署默认值;默认关闭归档,配置变化不触发删除。服务层用同一事务完成版本核对、幂等回执和审计快照,并在归档预览/导出时保护未确认交付、未知效果、未结束流程和有效幂等身份。具体窗口、候选目录和控制台接口见[数据保留与归档策略](api/retention.md)。
## 文件归档作业模型
`internal/retention` 中的 `JobSpec`、`JobManifest`、`JobBatch` 和 `JobStatus` 定义固定作业范围、不可变清单、连续批次及汇总状态;各持久字段提供中文注释。它们只存入私有归档目录,不新增数据库表。连续文件与摘要链就是恢复检查点,作业 `JobID` 进入现有管理审计的执行者身份。协议与失败边界见[文件归档作业](api/retention-jobs.md)。
### 周期归档(000031)
`orbit_archive_plans` 以命名空间和类别唯一,保存配置版本、保留策略版本、卷身份、周期秒数、批大小、字节预算、下次触发时间和未结清作业占位。失败也占位,关闭计划只停止新作业。
`orbit_archive_jobs` 固定范围和卷,保存 PENDING/RUNNING/SUCCEEDED/FAILED/ABANDONED 状态、版本、领取次数、已确认的批数/根数/字节/链摘要,以及随机租约代次和截止。租约确认必须匹配代次与版本且未过期;人工操作需要原版本、幂等键与审计原因。两个表均无外键,关联在同一事务按命名空间核验。
数据库与文件不做跨资源事务。持久文件是恢复依据,数据库链前缀是可信核验锚点;文件发布后确认丢失,由原作业重新读文件校验后收敛,缺失文件不自动重建。见[接口与部署说明](api/archive-scheduler.md)。

View File

@@ -1,6 +1,6 @@
# Orbit 持续开发与回归计划
更新:2026-09-21。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。
更新:2026-09-24。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。
## 1. 最终交付目标
@@ -36,15 +36,25 @@
### 控制台第一版
已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。
已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,使用数据库控制台账号和 HttpOnly 会话,响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有凭据与动作范围。首个管理员可以随 `orbit-admin bootstrap` 同事务创建,独立管理员身份和跨命名空间聚合仍不在控制台内提供。
控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线,并支持带 `control_revision` 的暂停、恢复和取消;对 `NEEDS_REVIEW` 实例进一步提供六类带证据的恢复入口(任务核查、步骤结论、继续、步骤重试、PENDING 接续、等待重建),页面只组装协议请求,当前修订、激活身份、权限、审计和状态门控由服务端负责。结果订阅详情已接入配置、积压窗口和交付历史,并支持启停及 EXPIRED 交付追加预算重试。权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。
定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。
控制台五个核心模块已拆为独立 hash 视图,导航状态和页面标题随视图同步,刷新或直接打开对应 hash 后仍保持正确模块。数据请求继续由统一 Bearer Token 和当前队列范围驱动。
控制台六个核心模块已拆为独立 hash 视图,导航状态和页面标题随视图同步,刷新或直接打开对应 hash 后仍保持正确模块。数据请求继续由统一会话和当前命名空间范围驱动。
已完成一次真实链路回归:标准 `scripts/test-mysql.sh` 在一次性 MySQL 和真实 Orbit 进程中跑通任务、Worker、结果订阅、Weave 流程、进程恢复及独立业务示例;控制台五个入口、健康检查和详情所依赖的读取 API 已用真实结果核对,证据见 [Console 真实链路回归](reports/phase-7-console-e2e.md)。这关闭了本地闭环验证项,生产拓扑和发布门槛仍保持未验收。
已完成一次真实链路回归:标准 `scripts/test-mysql.sh` 在一次性 MySQL 和真实 Orbit 进程中跑通任务、Worker、结果订阅、Weave 流程、进程恢复及独立业务示例;控制台前五个入口、健康检查和详情所依赖的读取 API 已用真实结果核对;第六个保留策略入口另有真实 MySQL、首次部署 HTTP 与浏览器操作回归,证据见 [Console 真实链路回归](reports/phase-7-console-e2e.md)。这关闭了本地闭环验证项,生产拓扑和发布门槛仍保持未验收。
新增 `make console-e2e` 作为首次部署闭环门禁:在一次性 MySQL 和 Docker Orbit 中从空库迁移、初始化控制台管理员,完成登录、受保护页面、WS Worker 成功执行、明确失败重试、定时任务、控制台任务读取和登出后的 401 校验,证据见[首次部署与控制台任务闭环](reports/phase-8-console-e2e.md)。该门禁证明功能链路可重复,不替代目标生产拓扑、容量和 RPO/RTO 验收。
新增 `deploy/docker-compose.yml` 作为单节点可部署基线:MySQL 健康检查通过后依次执行迁移和 `orbit-admin verify`,核验成功才启动 Orbit;`make compose-check` 提供不泄露密码的静态配置门禁,首次管理员初始化和域名/同源访问说明见[Compose 部署基线](22-compose-deployment.md),实际正常启动与失败阻断证据见[Compose 部署基线验证](reports/phase-9-compose-deployment.md)。该基线不替代生产数据库高可用、正式 Secret、容量、备份和 RPO/RTO 验收。
新增 `deploy/nginx/orbit.conf.example` 作为可选 HTTPS/WSS 入口模板:Compose 默认只将业务端口绑定到 `127.0.0.1`,Nginx 负责 TLS 终止、WebSocket 升级、健康探针代理和控制台登录来源限速;配置语法及端口边界证据见[HTTPS/WSS 入口基线验证](reports/phase-10-ingress.md)。Nginx 是部署入口,不会成为 Orbit 服务端的业务依赖,真实域名、正式证书、入口故障和公网重连仍需目标环境验收。
新增 `deploy/docker-compose.ha.yml` 作为两节点隔离验收覆盖:节点共享持久化 MySQL 状态但使用独立 HTTP 端口,base 服务中的单节点 Orbit 通过 profile 关闭;两个 `ready` 节点均通过后才能接入入口,滚动停止和重新拉起按[Compose 部署基线](22-compose-deployment.md)执行。该覆盖不替代跨故障域节点、数据库自动切换、负载均衡器和目标容量验收。
两节点覆盖已完成一次真实 Docker 回归:空库迁移和权限核验后两个节点均就绪,停止 A 后 B 保持就绪,A 重新拉起并重新核验后恢复;证据见[两节点 Compose 覆盖验证](reports/phase-11-two-node-compose.md)。该结果只关闭单机双进程启动/滚动动作,不关闭生产 HA。
## 2. 持续推进方式
@@ -97,10 +107,10 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
### P6 首批实施顺序
1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。迁移器同时提供当前版本无外键新装基线;每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。数据库事务封装另对提交前 MySQL 1205/1213 做最多两次短退避重试,Commit 回执不明保持原身份核对语义,真实锁环回归见[P7当前回归状态](reports/phase-7-regression-status.md)。
2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定任务/交付/流程/Inbox状态计数、最近事件类别、终态耗时样本、固定事件阶段耗时、最早年龄、截断/新鲜度指标、提交前 MySQL 事务重试/耗尽计数和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供,运维人员也可用 `orbit-admin observe snapshot` 获取同一有界单节点快照;后台循环间隔、批量上限、Worker/Event连接上限、Hub领取协程和有界内存排队均支持有界环境配置,并在启动日志和固定指标中记录。仍需端到端外部效果延迟、全局持久吞吐、Inbox从事件产生到推进完成的精确阶段延迟、全局发送队列、多节点聚合、锁等待/死锁告警阈值和生产采集/通知验证;SDK受控重连风暴和真实慢消费者背压已补[边界回归](reports/phase-6-reconnect-storm.md)与[慢消费者报告](reports/phase-6-slow-consumer.md),跨节点重连仍待目标拓扑。
2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定任务/交付/流程/Inbox状态计数、最近事件类别、终态耗时样本、固定事件阶段耗时、最早年龄、截断/新鲜度指标、提交前 MySQL 事务重试/耗尽计数和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供,运维人员也可用 `orbit-admin observe snapshot` 获取同一有界单节点快照;后台循环间隔、批量上限、Worker/Event连接上限、Hub领取协程和有界内存排队均支持有界环境配置,并在启动日志和固定指标中记录。跨节点重连风暴、慢消费者、真实锁等待/死锁、磁盘增长已经加入定向回归入口;目标拓扑的全局持久吞吐、锁等待/死锁告警阈值和生产采集/通知验证仍需环境验收。
3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、提交不明故障切点、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,旧 Worker 会话被排空且立即过期,撤销允许在停用范围内执行且不改写历史记录。已用一次性 MySQL 定向验证旧令牌失效、新令牌重绑和零外键;仍需多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。
4. 已补[`orbit-admin retention inventory`](api/retention.md)只读盘点和[阶段报告](reports/phase-6-retention-inventory.md),能区分过期幂等、确定终态、未知效果、未终结流程、已确认/未确认交付、已消费/未消费Inbox、事件和审计规模;它不执行归档或删除。已补[容量模型](19-capacity-model.md),把企业数转换为任务、流程、事件、连接、写放大和保留期输入;仍需评审各类保留周期、归档格式、受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
5. 已补[`orbit-load`](api/load.md)有界受理负载工具及24次/1000次隔离样本,见[受理报告](reports/phase-6-load.md)。新增独立[`orbit-exec-load`](api/execution-load.md),通过真实Worker、任务结果和尝试历史核对,提供短/混合/中长任务的受理、排队、授权执行及完成耗时分布;具体环境、原始JSON和失败分母见[执行测量报告](reports/phase-6-execution-load.md)。新增[`orbit-workflow-load`](api/workflow-load.md),用本次发布版本的双分支并行流程,逐个核对不同任务、唯一尝试、来源、精确输出与本地调用;24/100实例及24实例截止负例已重新构建回归并绑定源码/二进制哈希,见[流程测量报告](reports/phase-6-workflow-load.md)。新增[`orbit-schedule-load`](api/schedule-load.md),已完成24/128次定时集中触发与24次计划量的截止负例,保存实际分母、唯一任务/尝试、来源与暂停收尾证据,见[定时测量报告](reports/phase-6-schedule-load.md)。Go 1.27.1 模块级门禁、MySQL 存储集成、关键 SDK/Orbit 进程故障组、24个可运行 SDK 示例真实服务串跑和 result-inbox/task-outbox 真实业务集成已复跑通过。已补[长期积压与资源曲线样本](reports/phase-6-backlog.md):32×8 共256个触发点,在1个Worker/容量1和180秒截止下保留173个成功、83个未核验任务,并以独立数据库读取记录等待积压曲线;该单节点样本不替代目标拓扑容量。SDK受控重连风暴及[真实慢消费者背压](reports/phase-6-slow-consumer.md)已补边界回归,积压脚本已加入 CPU/RSS、MySQL 资源、连接池等待、业务快照和事务重试计数采样;下一步补长期存储增长、跨节点重连与目标数据库拓扑容量。
4. 已补[数据库保留策略、预览和只读归档导出](api/retention.md)以及[`orbit-admin retention inventory`](api/retention.md)只读盘点。策略按命名空间存入 MySQL,控制台单管理员可修改,版本、原因和前后快照进入审计;默认关闭归档,源数据不会因配置变化自动删除。固定候选目录保护有效幂等身份、未确认交付、未知效果、未终结流程和未处理 Inbox;导出包带冻结策略版本和 SHA-256;控制台支持连续批次、累计数量、摘要校验和本批重下载,真实 MySQL 回归核对同时间戳分页、保护对象、版本及权限边界。已补[持久文件归档作业](api/retention-jobs.md):自动有限分批、原目录续跑、原子发布、容量限制、摘要链、离线核验及真实强杀恢复;尚未接入周期调度、控制台作业列表或跨节点自动接管。仍需在目标环境评审最终归档介质、备份恢复、删除执行器和保留周期,再执行兼容升级和容量负载。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
5. 已补[`orbit-load`](api/load.md)有界受理负载工具及24次/1000次隔离样本,见[受理报告](reports/phase-6-load.md)。新增独立[`orbit-exec-load`](api/execution-load.md),通过真实Worker、任务结果和尝试历史核对,提供短/混合/中长任务的受理、排队、授权执行及完成耗时分布;具体环境、原始JSON和失败分母见[执行测量报告](reports/phase-6-execution-load.md)。新增[`orbit-workflow-load`](api/workflow-load.md),用本次发布版本的双分支并行流程,逐个核对不同任务、唯一尝试、来源、精确输出与本地调用;24/100实例及24实例截止负例已重新构建回归并绑定源码/二进制哈希,见[流程测量报告](reports/phase-6-workflow-load.md)。新增[`orbit-schedule-load`](api/schedule-load.md),已完成24/128次定时集中触发与24次计划量的截止负例,保存实际分母、唯一任务/尝试、来源与暂停收尾证据,见[定时测量报告](reports/phase-6-schedule-load.md)。Go 1.27.1 模块级门禁、MySQL 存储集成、关键 SDK/Orbit 进程故障组、24个可运行 SDK 示例真实服务串跑和 result-inbox/task-outbox 真实业务集成已复跑通过。已补[长期积压与资源曲线样本](reports/phase-6-backlog.md):32×8 共256个触发点,在1个Worker/容量1和180秒截止下保留173个成功、83个未核验任务,并以独立数据库读取记录等待积压曲线;该单节点样本不替代目标拓扑容量。SDK受控重连风暴及[真实慢消费者背压](reports/phase-6-slow-consumer.md)已补边界回归,积压脚本已加入 CPU/RSS、MySQL 资源、连接池等待、业务快照和事务重试计数采样;已补跨节点重连、慢消费者与有界磁盘增长样本(见 [P1 定向报告](reports/phase-6-stability-p1.md));长期磁盘曲线与目标数据库拓扑容量继续按实际负载验收。
6. 已补[隔离备份恢复演练](api/backup-restore.md):源库和恢复库均为一次性 MySQL 容器,Go 1.27.1 构建的当前二进制在逻辑备份恢复后核对迁移账本、核心记录数量、固定索引和零外键;证据见[恢复报告](reports/phase-6-backup-restore.md)。该演练不替代生产备份介质、复制切换、跨版本恢复或 RPO/RTO 验收。
7. 已补[A18 数据库短暂不可用恢复演练](api/db-recovery.md):Go 1.27.1 构建的当前二进制在数据库停止期间返回 readiness 503,同卷恢复后返回 200,并用原权限范围继续受理任务;证据见[数据库恢复报告](reports/phase-6-db-recovery.md)。该演练不替代主从切换、复制确认、跨故障域或 RPO/RTO 验收。
8. 已补[MySQL GTID 复制与手工提升演练](reports/phase-6-mysql-replication.md):隔离源库/副本通过真实 Orbit HTTP 受理任务,源库停止后副本提升、结构核验和原幂等键重放通过;自动故障切换、复制延迟上限、目标拓扑及 RPO/RTO 仍待验收。
@@ -225,6 +235,8 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
- [P2 SDK 协议阶段记录](reports/phase-2-sdk-protocol.md)
- [P2 自动 Worker 运行器记录](reports/phase-2-worker-runtime.md)
- [P2 执行历史与大载荷记录](reports/phase-2-execution-history.md)
- [P1 稳定性与归档定向回归](reports/phase-6-stability-p1.md)
- [持久文件归档作业回归](reports/phase-6-retention-jobs.md)
### 状态事件时间线子项
@@ -232,4 +244,18 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
### P4/P5 阶段结论与下一批
[逐项证据与生产边界](reports/phase-4-5.md)已落表,P4/P5平台功能阶段验收通过。W06/W09/W14/W15真实竞争和全停、A04未送达、W01/W13提交断线及W16旧结果重放竞争均有对应回归,不重复列为待开发。下一批首先补运行指标与告警依据,再按上方P6顺序推进凭据轮换、保留/归档、容量、备份恢复与生产数据库故障验证;P7负责剩余场景和版本发布。完整首版目标保持进行中。
[逐项证据与生产边界](reports/phase-4-5.md)已落表,P4/P5平台功能阶段验收通过。W06/W09/W14/W15真实竞争和全停、A04未送达、W01/W13提交断线及W16旧结果重放竞争均有对应回归,不重复列为待开发。运行指标、凭据轮换、保留策略及只读归档、隔离容量样本和备份恢复均已按上方 P6 留有验证记录;后续重点是归档介质与删除执行器、长期磁盘增长、目标生产拓扑的容量/数据库故障及 RPO/RTO 验收。P7 负责剩余场景和版本发布。完整首版目标保持进行中。
### 周期归档与控制台作业(本批)
- 已实现:000031 无外键计划/作业模型及中文注释;按类别配置周期与预算;数据库时间合并错过周期;固定版本/卷身份;有界领取、过期接管与迟到隔离;连续文件链和数据库前缀核验;单作业及全卷预算;单管理员页面、分页、失败重试/关闭、幂等与审计。
- 已验证:真实 MySQL 多节点竞争、落盘后确认丢失、文件损坏、策略变化、预算提高与卷预算、分页;标准测试、容器端到端、页面实际操作与全量集成回归均已通过。
- 不计入本批完成项:源数据删除、跨作业增量去重、对象存储、任意网络文件系统语义及目标生产跨故障域验收。
- [接口/部署/恢复示例](api/archive-scheduler.md)与[验证记录](reports/phase-6-archive-scheduler.md)。
### 可复用 Outbox 与 HTTP 服务生命周期(本批已完成)
- 已交付独立 MySQL 适配模块,支持调用方 EnqueueTx、受理确认钩子和有界 Run;旧 CLI 改用公共包,原有表结构及命令兼容。
- 已交付完整 HTTP 宿主:业务接口、发送器、Worker、持久 Inbox、健康检查、后台错误传播、有界关闭和数据库最后释放。
- 已通过:标准竞态测试、vet/build、SDK隔离和文档链接;定向真实MySQL/平台回归与平台全量 integration 均退出0。包括事务回滚/重复/旧快照、确认回滚、提交回执丢失、强杀恢复和SIGTERM收尾。
- [公共包接口](sdk/outbox-mysql.md)、[完整调用和故障示例](examples/http-service.md)、[验收报告](reports/outbox-http-integration.md)。本批不扩展到其他数据库/ORM、流程命令Outbox或源数据删除。

View File

@@ -80,7 +80,7 @@ docker run --rm \
registry.example/orbit:dev
```
`ORBIT_CONSOLE_QUEUE_ID` 是控制台的可选默认队列范围,只用于打开 `/console/` 时预填队列 ID,不是认证凭据。服务端仍要求业务请求携带有权限的 Bearer Token;多队列部署可以省略此变量,改为在浏览器选择凭据 JSON,由其中的 `queue_id` 自动填充。
`ORBIT_CONSOLE_QUEUE_ID` 是控制台的可选默认队列范围,只用于打开 `/console/` 时预填队列 ID,不是认证凭据。控制台登录使用数据库中的管理员账号,会话建立后服务端按账号绑定的业务凭据读取统计和任务;浏览器不需要填写 API 地址或上传凭据 JSON。多队列部署可以省略此变量,管理员在控制台内选择有权限的队列范围。首次管理员通过 `orbit-admin bootstrap` 创建,后续账号使用 `orbit-admin console-user create` 创建。
生产编排应先完成迁移和 `verify`,再启动新节点。服务收到 `SIGTERM` 后停止接收新连接并在有限窗口内关闭 HTTP、WebSocket 和后台循环;编排器的终止宽限期必须覆盖该窗口。启动后才能把节点加入流量池。

View File

@@ -0,0 +1,154 @@
# Orbit Compose 部署基线
本文给出单节点部署的可重复入口,适合开发、验收和小规模生产前置环境。它把 MySQL、迁移、schema 核验和 Orbit 服务串成一条有明确失败边界的启动链路;生产多节点、数据库高可用、备份恢复和容量门禁仍按[生产验收](05-production-acceptance.md)执行。
前置条件:Docker Engine、Docker Compose v2.20 或更高版本;如果使用仓库里的 Nginx 示例,需要 Nginx 1.25.1 或更高版本;正式环境还应具备镜像仓库、Secret 管理器和 HTTPS 反向代理。
## 1. 启动契约
`deploy/docker-compose.yml` 使用同一个 Orbit 镜像启动 Orbit 相关服务,并使用一个短生命周期的 MySQL 客户端服务收紧运行账号权限:
1. `mysql` 先通过 MySQL 8.4 健康检查。
2. `migrate` 使用迁移账号执行 `orbit-migrate up`。
3. `grant` 撤销 MySQL 初始化时给运行账号的全库权限,只保留 Orbit 运行所需的 `SELECT`、`INSERT`、`UPDATE`、`DELETE`。
4. `verify` 使用收紧后的运行账号执行 `orbit-admin verify`。
5. `orbit` 只有在迁移、权限收紧和核验都成功后才启动,运行账号不承担迁移职责。
迁移或核验失败时,Compose 的 `service_completed_successfully` 依赖会阻止 Orbit 进入流量。修复数据库或发布物后,用同一项目重新执行 `docker compose up -d`;不要通过删除迁移记录或手工改表来绕过核验。
Compose 文件要求显式提供数据库密码,仓库没有默认密码,也不读取隐式凭据。密码应来自部署平台的 Secret 注入,不应提交到 Git、镜像或日志。
## 2. 首次启动
先构建不可变镜像。正式发布时应把 `ORBIT_IMAGE` 换成带版本和摘要的镜像地址:
```sh
docker build \
--build-arg VERSION=1.0.0 \
--build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t orbit:1.0.0 .
```
在当前 shell 或 Secret 管理器中注入配置。以下值仅是变量示例,不是生产密码:
```sh
export ORBIT_IMAGE=orbit:1.0.0
export ORBIT_DB_NAME=orbit
export ORBIT_DB_USER=orbit_runtime
export ORBIT_DB_PASSWORD='从秘密管理器读取'
export ORBIT_DB_ROOT_PASSWORD='从秘密管理器读取'
export ORBIT_HTTP_BIND=127.0.0.1
export ORBIT_HTTP_PORT=8080
```
`ORBIT_DB_NAME` 和 `ORBIT_DB_USER` 仅使用字母、数字和下划线;权限收紧服务会拒绝其他字符。数据库密码由 Secret 管理器注入,建议使用字母、数字和短横线组成的随机值,避免 DSN 转义差异。
首次启动先只拉起 MySQL、迁移、权限收紧和核验:
```sh
docker compose -f deploy/docker-compose.yml up -d mysql verify
docker compose -f deploy/docker-compose.yml ps
docker compose -f deploy/docker-compose.yml logs --no-log-prefix migrate grant verify
```
`verify` 成功后,使用同一镜像和数据库连接创建初始业务资源与控制台管理员。密码通过标准输入传入,不能写在命令行参数中:
```sh
credential_dir="$(mktemp -d)"
umask 077
printf '%s\n' "$ORBIT_CONSOLE_PASSWORD" | \
docker compose -f deploy/docker-compose.yml run --rm -T --no-deps \
--entrypoint /usr/local/bin/orbit-admin \
-e ORBIT_MYSQL_DSN="root:${ORBIT_DB_ROOT_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME:-orbit}?parseTime=true&multiStatements=true" \
-v "$credential_dir:/run/orbit" migrate bootstrap \
--application platform \
--namespace default \
--environment production \
--queue default \
--worker-access \
--schedule-access \
--console-username "$ORBIT_CONSOLE_USERNAME" \
--console-display-name 'Orbit 管理员' \
--console-password-stdin \
--output /run/orbit/credentials.json
```
将 `credentials.json` 按 Secret 文件处理,仅用于保存业务接入凭据;控制台登录使用上面创建的用户名和密码。完成安全保存后再启动服务:
```sh
export ORBIT_CONSOLE_QUEUE_ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["queue_id"])' "$credential_dir/credentials.json")"
docker compose -f deploy/docker-compose.yml up -d orbit
curl --fail http://127.0.0.1:"${ORBIT_HTTP_PORT:-8080}"/health/ready
```
如果只需业务 SDK 接入,可以省略控制台管理员;如果要创建多个管理员,使用 `orbit-admin console-user create`,每个账号单独审计和轮换,不共享密码。
## 3. 访问和域名
Orbit Console 使用当前页面的同源地址调用服务端,浏览器不需要再填写 API URL,也不需要上传凭据 JSON 才能查看控制台。直接访问 `http://主机:端口/console/`,未登录会跳转到 `/console/login`。
接入域名时,参考 [`deploy/nginx/orbit.conf.example`](../deploy/nginx/orbit.conf.example),将 HTTPS 反向代理转发到 Orbit 的 `8080` 端口,并保留 WebSocket Upgrade/Connection 头;浏览器访问的域名就是 Console 自动使用的 API 地址。`ORBIT_HTTP_BIND` 控制主机监听地址,`ORBIT_HTTP_PORT` 控制主机映射端口;两者都不是业务域名配置。生产建议保持 `ORBIT_HTTP_BIND=127.0.0.1`,只允许同机 Nginx 访问。TLS 证书、域名和安全头由反向代理或入口网关管理。
修改 Nginx 配置后先执行 `nginx -t`,再平滑加载配置;不要在未验证证书、HTTP 到 HTTPS 跳转和 WSS 升级前切换公网流量。Nginx 不应代理 Orbit 的独立 metrics 监听,metrics 只在内部采集网络开放。
## 4. 日常操作与失败处理
```sh
# 查看迁移、核验和服务状态
docker compose -f deploy/docker-compose.yml ps
# 读取结构化服务日志
docker compose -f deploy/docker-compose.yml logs --tail=200 orbit
# 发布新镜像:先完成迁移和核验,再启动新服务
docker compose -f deploy/docker-compose.yml up -d verify
docker compose -f deploy/docker-compose.yml up -d orbit
# 停止服务但保留数据库卷
docker compose -f deploy/docker-compose.yml down
```
Compose 没有为 scratch 镜像伪造容器内 HTTP healthcheck;编排器或入口网关应调用 `/health/live`、`/health/ready` 和 `/version`。`ready` 返回 `503` 时摘除节点,保留原幂等键并等待数据库恢复。只有明确的废弃环境才使用 `docker compose down -v`,生产数据卷不得随服务停止删除。
## 5. 两节点覆盖与滚动替换
需要在同一台主机验证多节点接管时,叠加 `deploy/docker-compose.ha.yml`。两个 Orbit 节点共享 MySQL、迁移账本、租约和流程检查点;每个进程仍生成独立节点标识,停止一个节点不会把另一个节点的持久化执行权一并停止:
```sh
export ORBIT_HTTP_BIND=127.0.0.1
export ORBIT_HTTP_PORT_A=8081
export ORBIT_HTTP_PORT_B=8082
docker compose \
-f deploy/docker-compose.yml \
-f deploy/docker-compose.ha.yml \
up -d mysql verify orbit-a orbit-b
curl --fail http://127.0.0.1:8081/health/ready
curl --fail http://127.0.0.1:8082/health/ready
```
入口层把 Nginx `upstream orbit_backend` 改为两个后端:
```nginx
upstream orbit_backend {
server 127.0.0.1:8081;
server 127.0.0.1:8082;
keepalive 64;
}
```
滚动替换时先让入口健康检查摘除一个节点,再停止并更新它;确认另一节点 `ready` 后再恢复流量,最后启动更新后的节点并等待两个后端都通过健康检查:
```sh
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.ha.yml stop orbit-a
# 更新镜像标签后重新拉起 orbit-a;迁移和 verify 仍必须先成功
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.ha.yml up -d orbit-a
```
这个覆盖配置用于单机和隔离验收;生产环境应把两个 Orbit 节点放在不同故障域,前置负载均衡器负责健康摘除,MySQL 由独立数据库高可用方案提供,不把单机 Compose 多节点样本当成生产 HA 结论。
## 6. 当前边界
该文件是可重复的单节点启动基线,不代表自动数据库故障切换、跨故障域多副本、备份恢复或容量目标已经验收。生产上线还需要真实 MySQL 拓扑、滚动替换、凭据轮换、备份恢复、压测和 RPO/RTO 证据;对应门槛以[交付核对清单](release-checklist.md)为准。

View File

@@ -20,8 +20,15 @@
| [19 容量模型](19-capacity-model.md) | 企业规模到任务、流程、数据库与连接负载的推导及生产验收方法 |
| [P7 协议兼容矩阵](reports/phase-7-compatibility.md) | 当前协议门禁、历史提交双向任务 API 演练与正式标签矩阵边界 |
| [P7 Console 真实链路回归](reports/phase-7-console-e2e.md) | 真实 MySQL、Worker、流程、结果订阅与控制台读取闭环 |
| [首次部署与控制台任务闭环](reports/phase-8-console-e2e.md) | 从空库初始化管理员、Docker 启动、控制台登录、WS Worker、重试和定时任务 |
| [Compose 部署基线验证](reports/phase-9-compose-deployment.md) | MySQL、迁移、核验、Orbit 启动顺序及失败门禁 |
| [HTTPS/WSS 入口基线验证](reports/phase-10-ingress.md) | 本机端口边界、Nginx TLS、WebSocket 升级和健康探针配置 |
| [20 运行与发布手册](20-operations-runbook.md) | 启动、发布、滚动停止、数据库恢复、备份和事故处理 |
| [21 容器发布说明](21-container-deployment.md) | 最小镜像、迁移/核验/启动顺序、探针和发布边界 |
| [22 Compose 部署基线](22-compose-deployment.md) | MySQL、迁移、核验、Orbit 服务和首次管理员初始化的可重复启动链路 |
| [Nginx HTTPS/WSS 入口示例](../deploy/nginx/orbit.conf.example) | 域名、TLS、WebSocket 升级、健康探针和控制台登录限速 |
| [Compose 两节点覆盖](../deploy/docker-compose.ha.yml) | 共享 MySQL 状态、双节点就绪和滚动替换验收入口 |
| [两节点 Compose 覆盖验证](reports/phase-11-two-node-compose.md) | 双节点启动、单节点停止后的对等节点可用和重新加入 |
| [09 可靠结果订阅](09-result-subscription-core.md) | 提交水位、冻结结果与可靠交付 |
| [10 Weave决策与交付](10-workflow-engine-core.md) | 有限步骤决策和内部结果Inbox |
| [11 Weave实例受理](11-workflow-admission.md) | 固定版本、初始图和首批任务事务 |
@@ -32,6 +39,8 @@
| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 |
| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 |
| [Orbit Console](console.md) | 内置控制台、本地打开方式与权限边界 |
| [保留策略](api/retention.md) | 管理端窗口配置、候选保护和只读快照 |
| [文件归档作业](api/retention-jobs.md) | 自动有限分批、断点续跑、容量限制和离线核验 |
## 已确认的决策
@@ -99,3 +108,7 @@ Orbit 不内置特定行业规则;Pulse 管理执行,Weave 管理步骤依
持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md)、[P0 报告](reports/phase-0.md)、[P1 报告](reports/phase-1.md) 与 [P2 阶段验收](reports/phase-2.md) 、[P3 安全重试报告](reports/phase-3-safe-retry.md) 、[P3 计划调度报告](reports/phase-3-schedules.md) 和 [P3 完整范围验收](reports/phase-3.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。
[实例查询接口与SDK](api/workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
- [周期归档与作业管理](api/archive-scheduler.md)
- [周期归档与作业管理验证记录](reports/phase-6-archive-scheduler.md)

View File

@@ -64,3 +64,5 @@ P1 业务接口已遵守下述约定,后续接口继续沿用:
实现时必须补齐:用途与权限、HTTP 方法和路径、请求头、字段类型/必填/默认值/限制、完整请求与响应、错误码、幂等与并发规则、超时/重试/取消语义、SDK 对应方法、可运行示例、兼容版本和已覆盖的回归场景。
[流程实例筛选分页与权限](workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
- [周期归档与作业管理](archive-scheduler.md)

View File

@@ -0,0 +1,146 @@
# 周期归档与作业管理
Orbit 控制台的「归档作业」页面提供周期配置、作业列表、失败重试及关闭。沿用单管理员账号密码登录,不引入角色体系。业务 SDK 不需要也不能领取归档作业。
## 1. 部署执行器
需要 Schema **31**。停止旧节点后执行 `orbit-migrate up`,再启动新版本节点。000031 只增加 `orbit_archive_plans`、`orbit_archive_jobs`,不包含外键或源数据清理。
```dotenv
# 必须挂载持久卷,并让 Orbit 运行用户拥有目录,权限 0700。
ORBIT_ARCHIVE_ROOT=/var/lib/orbit/archive
# 初次部署生成并保存一个固定的 32 位小写十六进制 ID,例如 openssl rand -hex 16。
ORBIT_ARCHIVE_STORAGE_ID=11111111111111111111111111111111
# 全卷逻辑文件字节预算,默认 10 GiB;范围 1 MiB–1 TiB。
ORBIT_ARCHIVE_MAX_BYTES=10737418240
# 默认 10s,范围 1s–1m。
ORBIT_ARCHIVE_TICK_INTERVAL=10s
```
`ROOT` 和 `STORAGE_ID` 均为空时关闭此节点后台执行器,控制台仍可查询历史。两者必须一起设置。路径只来自部署配置,页面不能指定任意服务器目录。镜像用户为 `65532:65532`,目录须可由该用户访问。
运行示意(`orbit.env` 同时包含已有数据库等配置,不要提交到 Git):
```sh
mkdir -p ./archive-data
chmod 700 ./archive-data
# Linux 主机上按镜像实际 UID/GID 配置归档目录归属。
sudo chown 65532:65532 ./archive-data
docker run --name orbit --env-file orbit.env \
--mount type=bind,src="$(pwd)/archive-data",dst=/var/lib/orbit/archive \
-p 127.0.0.1:8080:8080 orbit:local
```
同一 `STORAGE_ID` 的所有执行节点**必须挂载同一物理卷**,支持 POSIX 文件锁、原子硬链接和目录 fsync;同名路径、复制目录或相同 ID 不等于共享存储。卷根目录持久化 `.storage-id` 身份标记,恢复已有作业时要求原清单存在。独立本地盘使用不同卷 ID;其归档作业不能由别的独立盘接管。没有验证任意 NFS/对象存储语义,不据此宣称跨故障域归档高可用。
同卷执行串行,后台每轮最多调度 16 个到期类别、执行一个文件批次,工作截止 45 秒,数据库租约 5 分钟。正常继续在下一轮领取;进程异常退出且未确认时,等待租约到期后恢复。关机取消不写成文件损坏。
## 2. 使用顺序
1. 登录控制台,在「保留策略」确认各类保留天数,开启只读归档并保存。
2. 打开「归档作业」,选择类别,配置间隔、批大小、单作业容量和变更原因,开启周期并保存。
3. 首次启用立即到期,后台生成固定身份的作业;页面每 10 秒刷新,显示批次、根记录数、文件字节和链尾 SHA-256。
4. 失败时查看分类,修复原因后填写处理原因,从原作业重试;不能恢复的旧版本作业可以关闭,原文件与审计保留。
任务、流程、事件、审计、交付、Inbox、幂等记录各有独立计划。间隔范围 **1–720 小时**,HTTP 支持 3600–2592000 整数秒;每批 **1–100** 条根记录,单作业 **1 MiB–1 GiB**。关联历史行不计入根记录数,但全部计入文件容量。
同一类别最多一个未结清作业,失败也占位,避免周期不断制造重复失败。错过多个周期合并触发一次,`next_run_at` 按本次数据库时间加周期计算,不补齐每个错过的时间槽。关闭计划仅停止新作业,已有作业继续结清。
每次周期是**新的有限全量候选快照**:会再次读取仍满足保留窗口的源记录,不是跨作业增量清理。每个作业内的分页截止、游标、类别、策略版本及批大小固定。文件落盘后才确认数据库进度;已落盘而未确认的文件可在恢复时重新核验并收敛。源数据库内容不删除。
保留策略修改后,计划仍绑定旧版本,因此不会再生成新作业,须重新保存计划确认新版本。正在运行的旧作业在下一次导出时以 `POLICY_CHANGED` 停止;若已完整落盘,则可核验并确认已完成的原快照。旧作业重试不能更换策略版本或重置截止。
## 3. HTTP 接口
以下接口只接受控制台 HttpOnly 会话 Cookie,普通 Bearer 凭据不可访问。命名空间取自会话,写请求拒绝跨来源浏览器调用。保存与人工操作必须提供一个 `Idempotency-Key`;网络或服务器错误后保持原路径、原请求体和原键重试。明确的 4xx 拒绝可核对并修正后使用新键。
### 查询周期配置
`GET /console/archive/plans`
```json
{"data":{"executor_configured":true,"storage_id":"11111111111111111111111111111111","items":[{"namespace_id":"22222222222222222222222222222222","kind":"tasks","revision":"1","enabled":true,"policy_revision":"3","storage_id":"11111111111111111111111111111111","interval_seconds":86400,"batch_size":20,"max_bytes":268435456,"next_run_at":"2026-09-24T02:00:00Z","active_job_id":null,"updated_at":"2026-09-23T02:00:00Z"}]}}
```
实际返回全部七类。未保存的类别 `revision="0"`、`enabled=false`,没有卷绑定,时间为空。`executor_configured` 只表示当前 HTTP 节点已配置执行器,不表示实时健康探针或整个集群状态。
### 保存周期配置
`PUT /console/archive/plans/{kind}`
```http
Content-Type: application/json
Idempotency-Key: archive-plan-change-001
```
```json
{
"expected_revision":"0",
"policy_revision":"3",
"enabled":true,
"interval_seconds":86400,
"batch_size":20,
"max_bytes":268435456,
"reason":"每日归档已超过保留窗口的任务历史"
}
```
全部字段必填,原因是 1–1024 个 UTF-8 字节的非空单行。响应 `data` 为保存后的计划。计划版本或保留策略版本不符返回 409;开启时保留策略必须已启用,当前节点必须配置卷。关闭已有计划时允许当前节点未配置执行器。存在未结清作业时不允许更换卷绑定。
### 查询作业列表
`GET /console/archive/jobs`,下一页 `GET /console/archive/jobs?before={next_before}`。
每页最多 20 条,按创建时间和 ID 倒序;位置必须是当前命名空间内的作业 ID。不是跨页冻结快照。返回:
```json
{"data":{"items":[{"id":"33333333333333333333333333333333","namespace_id":"22222222222222222222222222222222","kind":"tasks","plan_revision":"1","policy_revision":"3","storage_id":"11111111111111111111111111111111","batch_size":20,"max_bytes":268435456,"state":"SUCCEEDED","revision":"5","attempts":2,"batches":2,"roots":32,"bytes":86124,"last_sha256":"64位小写十六进制摘要","error_code":"","lease_until":null,"created_at":"2026-09-23T02:00:00Z","updated_at":"2026-09-23T02:00:20Z"}],"next_before":""}}
```
`attempts` 是批次领取次数,包含分批继续和崩溃重领,不是业务任务重试次数。`batches/roots/bytes/last_sha256` 只展示已经入库确认的进度,落盘后确认失败时页面可能暂时落后。列表不返回文件正文、分页游标或内部租约代次。
| 状态 | 含义 |
| --- | --- |
| PENDING | 等待首批或下一批执行 |
| RUNNING | 已领取,租约内执行;到期可接管 |
| SUCCEEDED | 全部批次落盘、校验并确认完成 |
| FAILED | 等待管理员核查;阻止本类别创建后续作业 |
| ABANDONED | 管理员关闭失败作业,文件、数据库历史与审计保留 |
### 重试或关闭失败作业
`POST /console/archive/jobs/{id}/actions`
```http
Content-Type: application/json
Idempotency-Key: archive-job-retry-001
```
```json
{"expected_revision":"8","action":"retry","max_bytes":536870912,"reason":"确认原归档卷完整,并将单作业预算提高到512MiB"}
```
响应 `data` 为操作后的作业。仅 FAILED 可操作;重试保持原 ID、目录、范围、截止与已确认链前缀,预算只可保持或提高。`action="abandon"` 关闭作业,`max_bytes` 必须等于原值;关闭后不删除文件,开启的计划可按到期时间生成新作业。原键重放仅返回原操作回执,不代表作业当前仍处于回执中的状态;随后查询列表确认实时状态。
## 4. 故障处理和文件核验
| 错误分类 | 处理方式 |
| --- | --- |
| STORAGE_LIMIT | 检查单作业预算和全卷预算;按需提高,或在运维确认备份后转移已经结清的文件。不会自动删除旧文件腾空间 |
| FILE_CHECK_FAILED | 检查原卷、目录、权限和文件完整性;恢复原字节后重试,禁止伪造摘要或跳过缺失批次 |
| STORAGE_BUSY | 同目录有其他 CLI/进程操作;核实旧执行者退出后重试 |
| POLICY_CHANGED | 原策略已变化或关闭;通常关闭旧作业,再按新版本保存计划 |
| PERMISSION_DENIED | 应用或命名空间停用;核查后按原状态重试或关闭 |
| EXPORT_FAILED | 数据库读取未完成;确认数据库恢复后用原作业重试 |
如果在首份清单落盘前进程退出、卷不可写或全卷预算耗尽,接管不会擅自创建一个新的空目录。此时可核查后关闭失败作业,让计划生成新作业。已有清单的容量故障可提高预算后重试原作业。
文件位于 `ORBIT_ARCHIVE_ROOT/{namespace_id}-{job_id}/`,清单及批次格式与 [文件归档 CLI](retention-jobs.md) 一致。数据库确认的链尾摘要可独立保存。服务端停止操作该目录后,可离线核验:
```sh
orbit-admin retention verify-archive \
--directory /var/lib/orbit/archive/22222222222222222222222222222222-33333333333333333333333333333333 \
--expected-sha256 "$TRUSTED_SHA256"
```
全卷预算按普通文件的逻辑大小累计,单次扫描最多 100000 个目录项,包含未完成、失败与已关闭作业;超过边界停止。并发同卷执行受全卷文件锁约束。它不等价于文件系统物理空间配额,不包含块分配/元数据开销,也不能约束绕过平台直接写目录的外部进程。生产使用独立持久卷,并监测实际磁盘余量。暂不提供文件删除、自动迁移介质、下载正文或对象存储接口。

View File

@@ -21,6 +21,23 @@ ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填
输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token / expires_at。初始凭据有效期为 30 天,权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。
如果希望在第一次初始化时同时创建控制台管理员,可在同一条 bootstrap 事务中提供账号,并从标准输入交付密码:
```sh
secret-manager read orbit/console-admin-password | \
go run ./cmd/orbit-admin bootstrap \
--application sample-app \
--namespace default \
--environment production \
--queue files \
--console-username admin \
--console-display-name 'Orbit 管理员' \
--console-password-stdin \
--output .local/production.credentials.json
```
`--console-password-stdin` 要求密码至少 12 个字节,密码不会出现在命令参数、日志或交付 JSON 中。业务凭据、控制台账号和两条初始化审计会在同一个数据库事务中提交;任何一步失败都会整体回滚。交付 JSON 只会额外包含 `console_username`,后续新增控制台账号仍使用 [`orbit-admin console-user create`](../console.md)。
资源、凭据摘要和初始化审计在一个事务中提交。交付文件先写入并同步到磁盘,成功后再提交事务。如果文件写入失败则回滚并移除文件;如果已经交付后提交无法确认,保留文件并报错,应使用其中凭据核对结果,不能直接覆盖或重复创建。
初始文件包含真实秘密,应保管在受限目录或导入秘密管理系统。运行账户与迁移/初始化账户分开;生产只通过 HTTPS 使用凭据。

View File

@@ -26,8 +26,8 @@ make release-manifest > orbit-release-manifest.json
"workflow_schema_version": 1
},
"schema": {
"current_version": 28,
"migration_count": 28,
"current_version": 30,
"migration_count": 30,
"migrations": [
{"version": 1, "name": "000001_initial.up.sql", "checksum": "<sha256>"}
]
@@ -47,7 +47,7 @@ ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' \
成功时输出类似:
```json
{"schema_version":28,"migration_count":28,"foreign_key_count":0,"required_index_count":6,"complete":true}
{"schema_version":31,"migration_count":31,"foreign_key_count":0,"required_index_count":6,"complete":true}
```
`complete=true` 表示当前二进制的迁移账本、Orbit 表零外键,以及 000026–000028 的六个有界观测索引均满足要求。需要同时核对发布物身份时,传入保存的清单:
@@ -79,3 +79,9 @@ ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' \
```
它与 `/metrics` 共享同一有界读取实现,但不会改变指标注册器或业务状态;遇到数据库超时会返回非零状态,调用方应保留上一次成功快照并排查数据库。
## 文件归档与离线核验
[`orbit-admin retention archive / verify-archive`](retention-jobs.md) 提供持久文件作业、原目录续跑及摘要链核验。读取命名空间需显式指定已保存策略版本,每个新批次记录独立作业审计,源记录不删除;离线核验无需数据库。
周期归档部署、持久卷及故障处理见[周期归档管理](archive-scheduler.md);需 Schema 31,默认关闭后台归档执行器。

115
docs/api/retention-jobs.md Normal file
View File

@@ -0,0 +1,115 @@
# 可恢复的文件归档作业
`orbit-admin retention archive` 自动按已保存的保留策略分批导出,并把每个批次可靠写入本地持久目录。它面向持有数据库运维权限的操作者,复用控制台导出的候选保护和版本核对,记录 `archive:<job_id>` 审计身份;不需要控制台 Cookie,也不向业务 SDK 增加管理权限。数据库业务记录始终保留。
本页介绍可独立运行的有限批次 CLI 执行器。周期触发、控制台作业列表与人工重试见[周期归档管理](archive-scheduler.md);对象存储和源数据清理尚未接入。
## 配置与启动
先在控制台“保留策略”保存所需窗口并允许导出,记下命名空间 ID 和策略版本。为每次扫描选择独立目录,父目录需提前创建,数据目录应位于受备份保护的持久卷中;进程会创建权限为 `0700` 的作业子目录和 `0600` 文件。
```sh
# 按实际数据库注入 ORBIT_MYSQL_DSN,不要把密码放入版本库。
export ORBIT_MYSQL_DSN='operator:password@tcp(127.0.0.1:3306)/orbit'
mkdir -p /srv/orbit-archives
chmod 700 /srv/orbit-archives
./bin/orbit-admin retention archive \
--namespace 11111111111111111111111111111111 \
--kind tasks \
--revision 1 \
--directory /srv/orbit-archives/tasks-20260923 \
--batch-size 20 \
--max-batches 10 \
--max-bytes 268435456 \
--timeout 5m
```
ID、日期、版本均为示例,使用当前环境的值。目录也可通过 `ORBIT_ARCHIVE_DIRECTORY` 配置,显式 `--directory` 优先;该环境变量只供归档 CLI 使用,不会使 Orbit 服务后台自动开始归档。
| 参数 | 默认/约束 |
| --- | --- |
| `--namespace` | 必填,32 位小写十六进制命名空间 ID |
| `--kind` | 必填,`tasks/workflows/events/audit/deliveries/inbox/idempotency` |
| `--revision` | 必填,已保存的非零策略版本 |
| `--directory` | 必填绝对路径,或设置 `ORBIT_ARCHIVE_DIRECTORY`;每个作业独立私有目录 |
| `--batch-size` | 默认 20,1–100;与范围一同固定在清单,恢复时不可更改 |
| `--max-batches` | 默认 10,1–1000;本次调用最多新增的批次数 |
| `--max-bytes` | 默认 256 MiB,1 MiB–100 GiB;本作业清单及已发布批次文件的总上限 |
| `--timeout` | 默认 5 分钟,1 秒–1 小时;单批数据库读取仍受 10 秒截止及原有行数/正文限制 |
所有调用仍核对数据库 Schema;每个新批次在事务内核对应用、命名空间启用状态和当前策略版本。不能使用已关闭或改版的策略继续生成新批次。
输出为摘要,不包含正文、凭据或游标:
```json
{"job_id":"<固定作业ID>","completed":false,"batches":10,"roots":200,"bytes":1234567,"last_sha256":"<清单及批次链的末端摘要>"}
```
命令达到 `--max-batches` 后正常返回;`completed=false` 明确表示尚未导出完。使用**相同目录、命名空间、类型、版本和批量**再次运行,自动校验已有批次后接续。可提高本次批数/容量/时间上限,不会改写已发布文件。已完成作业再次运行只验证,不再从头导出。
作业执行中遇到数据库错误、策略变更、文件损坏、权限问题或容量不足时输出最后已确认进度并以非零状态退出。参数或数据库连接等启动前校验失败只报错,不输出成功状态;此时可用离线核验命令读取已保存进度。调用方必须同时检查退出码和 `completed`,不能把“进程结束”当成“作业完成”。
## 文件结构与恢复
```text
/srv/orbit-archives/tasks-20260923/
├── .lock # 非阻塞进程文件锁;进程退出后自动释放
├── manifest.json # 不可变范围、随机作业ID及清单版本
├── batch-000001.json # 输入游标、服务端原始快照、上一文件摘要
├── batch-000002.json
└── ...
```
写入顺序是:数据库读取并记录导出审计 → 私有临时文件 → 文件同步 → 原子发布且拒绝覆盖 → 同步目录 → 原字节回读。没有独立的可变游标文件;连续且校验通过的批次本身就是检查点。
- 文件发布前退出:残留 `.write.tmp` 不算成功,恢复时清理该临时文件并重新导出同一输入游标。
- 文件发布后、进程报告成功前退出:新进程读取已发布批次,从其下一游标恢复。
- 缺少批次、摘要不一致、混入其他作业、符号链接、非私有文件或未知文件:立即停止,不跳过、不覆盖原档案。
- 数据库导出已记审计、随后磁盘写入失败:可能多出一次导出审计;审计不表示文件持久化成功。源数据不变,恢复后从最后文件继续。
- 相同目录的并发进程:后进入者立即失败,避免两个进程同时推进。
首批绑定清单原字节摘要,后续每批绑定前一文件的摘要,快照正文另按服务端 SHA-256 校验。保留窗口截止时间跨批固定;每批使用独立数据库快照,整个作业不承诺全库同一时间点。期间解除保护的旧记录可能需要下一次独立扫描。
## 离线核验
不需要数据库连接或网络:
```sh
./bin/orbit-admin retention verify-archive \
--directory /srv/orbit-archives/tasks-20260923
# 推荐另外保存成功输出中的 last_sha256,再以可信摘要核对完整链。
./bin/orbit-admin retention verify-archive \
--directory /srv/orbit-archives/tasks-20260923 \
--expected-sha256 '<另行保存的64位小写摘要>'
```
核验输出与执行输出结构相同;部分作业也可以核验,`completed=false` 时仍未完成。`--expected-sha256` 可发现文件与摘要整体重写或尾部截断;前提是期望摘要保存在档案目录之外的可信位置。未提供期望摘要时只能验证文件之间的内部一致性,不能证明来源真实性。
单批快照在 `batch-*.json` 的 `snapshot` 字段中,仍遵循[归档接口](retention.md)的 `format_version/sha256/content` 协议。
## Docker 运行
把持久卷挂载到容器目录,并确保镜像内 UID 65532 有权限创建 `0700` 子目录。数据库和路径按目标环境配置;使用当前服务相同的不可变镜像版本:
```sh
docker run --rm --network orbit-network \
--env-file /secure/orbit-archive.env \
--mount type=bind,src=/srv/orbit-archives,dst=/archives \
--entrypoint /usr/local/bin/orbit-admin orbit:local retention archive \
--namespace 11111111111111111111111111111111 --kind tasks --revision 1 \
--directory /archives/tasks-20260923
# 离线核验也需要文件锁,挂载目录需允许打开锁文件;不连接数据库。
docker run --rm --network none \
--mount type=bind,src=/srv/orbit-archives,dst=/archives \
--entrypoint /usr/local/bin/orbit-admin orbit:local retention verify-archive \
--directory /archives/tasks-20260923
```
当前文件执行器支持 Linux/macOS 本地 POSIX 文件锁、硬链接原子发布及文件/目录同步语义;其他系统显式拒绝。不同节点独立磁盘不共享进度;网络共享文件系统必须验证上述语义后才能使用,当前没有跨节点自动接管承诺。
容量上限覆盖一个作业的清单和已发布批次,不是整个卷的配额;还需预留一个临时批次空间。已有档案不会自动删除腾空间,不会自动生成新目录重试。每个作业最多 10000 批。该归档不是数据库备份,也不构成源数据删除授权。
周期触发、控制台作业列表与人工重试已由 Schema 31 增加,见[周期归档管理](archive-scheduler.md)。本页 CLI 仍可独立使用;独立 CLI 作业不会自动注册到管理列表。

View File

@@ -1,4 +1,119 @@
# 保留清单(只读)
# 数据保留与归档策略
Orbit 将保留策略作为命名空间级数据库配置保存。控制台保持单管理员模型,登录后打开“保留策略”即可查看和修改窗口;每次修改都要求上一版本号、幂等键和变更原因,前后值写入 `orbit_audit_events`。多个 Orbit 节点读取同一个 MySQL 版本,因此不会因为节点环境变量不同而产生不同清理边界。
首次安装和数据库策略尚未保存时使用部署默认值:幂等记录 90 天、任务历史 365 天、流程历史 365 天、事件日志 365 天、已确认交付 90 天、已处理 Inbox 90 天、管理审计 2555 天。默认 `enabled=false`,保存策略也不会立即删除数据。
环境变量只负责首次展示/未保存策略的默认值:
```text
ORBIT_RETENTION_IDEMPOTENCY_DAYS=90
ORBIT_RETENTION_TASK_DAYS=365
ORBIT_RETENTION_WORKFLOW_DAYS=365
ORBIT_RETENTION_EVENT_DAYS=365
ORBIT_RETENTION_DELIVERY_DAYS=90
ORBIT_RETENTION_INBOX_DAYS=90
ORBIT_RETENTION_AUDIT_DAYS=2555
```
窗口范围为 1–3650 天,幂等记录不得短于 90 天;所有节点应使用同一组默认值。数据库中已有策略后,环境变量不覆盖它。管理端的“允许导出只读归档”控制导出权限,导出仍须核对固定策略版本。
归档分两步:先用“预览”或 `GET /console/retention/preview` 查看各类候选,再按固定类别调用 `POST /console/retention/export` 分批生成只读 JSON 快照。每个快照包含冻结的策略版本、数据库时间、截止时间、根记录及关联历史,并返回 SHA-256;源表仍然保留,导出成功只记录摘要和数量审计,不代表可以删除。后续删除执行器必须在独立发布和备份恢复验收后接入,不能把导出接口当成删除接口。
候选筛选由服务端固定目录实现,浏览器不能提交表名或 SQL。终态任务必须没有未知效果、租约和流程所有权;流程必须终态且没有未结任务或未处理 Inbox;事件必须没有未确认交付或未处理 Inbox;交付只允许已确认记录;Inbox 只允许已处理且流程已终态记录;幂等记录需同时超过过期时间和窗口。审计单独使用审计窗口,普通清理不处理审计记录。未确认交付、未处理 Inbox、未知效果、进行中的任务/流程和有效幂等身份始终受保护。
控制台接口示例:
```sh
# 已登录控制台会话;浏览器通常直接使用页面,不需要手工传令牌
curl -b orbit-console.cookie http://127.0.0.1:8080/console/retention
curl -b orbit-console.cookie -X PUT http://127.0.0.1:8080/console/retention \
-H 'Content-Type: application/json' -H 'Idempotency-Key: retention-2026-q3' \
--data '{"expected_revision":"0","enabled":false,"idempotency_days":90,"task_history_days":365,"workflow_history_days":365,"event_log_days":365,"delivery_history_days":90,"inbox_history_days":90,"audit_log_days":2555,"reason":"初始生产保留评审"}'
```
## 管理页操作
1. 登录后打开 `/console/#/retention`,调整窗口、填写单行原因后保存;原因最多 1024 UTF-8 字节。幂等记录最少 90 天。
2. 点击“预览候选”:每类最多展示 100 条,`100+` 表示还有更多,并非全库总数。修改未保存时不能预览或导出,避免使用错误版本。
3. 需要导出时开启“允许导出只读归档”并保存,选择历史类型,点击“导出首批”。每批最多 20 条根记录,关联历史一并导出。浏览器需要 HTTPS 或 localhost 安全上下文来执行 SHA-256 校验。
4. 页面显示本批、累计条数和完整摘要;有后续记录时显示“导出下一批”,到末批后停止。可“重新下载本批”,切换历史类型或保存新策略会重置进度。“从首批开始”重新扫描,不会清理已下载文件。
下载文件保存 `format_version`、`sha256`、原始字符串 `content` 三个字段。页面关闭或刷新后内存中的分页进度会丢失;文件中的 `content.next_cursor` 可用于接口继续导出。服务端提交后网络中断可能多记录一次导出审计,但不改变任何源业务记录;页面未成功校验时不推进游标。修改策略的网络重试在当前页面复用原幂等键。
## 导出接口与分页
这些接口仅允许已登录控制台会话;业务 Bearer 凭据不能代替管理员会话。
| 接口 | 请求/结果 |
| --- | --- |
| `GET /console/retention` | `data` 为当前策略,`revision` 是字符串;未保存版本为 `"0"` |
| `PUT /console/retention` | 必填全部窗口、`enabled`、字符串 `expected_revision`、单行 `reason`,请求头必填 `Idempotency-Key`;幂等返回同一版本 |
| `GET /console/retention/preview` | `data.policy` 为所用策略,`sample_limit=100`;各类别包含 `kind/cutoff/count/more` |
| `POST /console/retention/export` | `kind`、字符串 `expected_revision` 必填;`limit` 默认 20、最大 100;`cursor` 默认空 |
响应采用标准 `{"data": ...}` 信封。导出请求示例:
```sh
curl -b orbit-console.cookie http://127.0.0.1:8080/console/retention/export \
-H 'Content-Type: application/json' \
--data '{"kind":"tasks","expected_revision":"1","limit":20}' \
--output export-response.json
```
`data.content` 是原始 JSON 文本,解析后包括:
| 字段 | 含义 |
| --- | --- |
| `policy` / `kind` | 本批实际策略与根记录类别 |
| `snapshot_at` / `cutoff` | 数据库读取时间与严格早于的候选截止时间,UTC |
| `source_retained` | 始终为 `true`,源数据保留 |
| `root_count` | 本批根记录数量,不含关联历史计数 |
| `tables` | 固定目录下的表与行;二进制为十六进制,整数和 JSON 正文按文本保留精度 |
| `next_cursor` | 非空表示可以继续;下一次请求原样传入相同 `kind` 和 `expected_revision` |
游标绑定命名空间、历史类型、策略版本与截止时间;不接受客户端表名或 SQL。策略变化后必须重新核对并从首批开始。每批使用独立数据库一致性快照,多个批次不是同一个全库时间点;期间从受保护状态转为候选的记录可能需要重新扫描。归档不是灾难恢复备份,不能直接把这些 JSON 导入生产库。
每个导出事务最多 10 秒、10000 条关联记录、8 MiB 正文;超限整体失败,不返回截断快照。减少根记录 `limit` 后可重新尝试;单个根记录本身过大时需要另行处理,不能忽略其历史。每次成功导出记录类别、策略版本、数量和摘要审计。
| 情况 | 处理 |
| --- | --- |
| 未登录/会话已失效 | 401,重新登录 |
| 归档关闭或策略版本不匹配 | 409,读取策略并重新核对 |
| 不支持的类别、游标不匹配、数量越界、快照超限 | 400,修正参数;超限时减小批量 |
| 修改策略结果不明 | 使用完全相同的内容和 `Idempotency-Key` 重试 |
离线核验及生成下一批请求(兼容控制台下载信封和上述 curl 响应):
```python
import hashlib
import json
from pathlib import Path
# 控制台下载文件也可替换这里的文件路径。
response = json.loads(Path("export-response.json").read_text(encoding="utf-8"))
snapshot = response.get("data", response)
assert snapshot["format_version"] == 1
content = snapshot["content"].encode("utf-8")
assert hashlib.sha256(content).hexdigest() == snapshot["sha256"], "快照校验失败"
page = json.loads(content)
assert page["source_retained"] is True
print("本批根记录:", page["root_count"])
if page["next_cursor"]:
Path("next-request.json").write_text(json.dumps({
"kind": page["kind"], "expected_revision": page["policy"]["revision"],
"limit": 20, "cursor": page["next_cursor"],
}), encoding="utf-8")
# 下一批:curl ... --data-binary @next-request.json --output export-response-2.json
```
摘要针对 `content` 的原始 UTF-8 字节,不对解析后重新格式化的 JSON 计算。SHA-256 用于发现内容损坏;摘要与正文一起被篡改时无法证明真实性,需要把审计摘要或外部签名另行保存在可信介质中。
## 自动分批与持久归档
需要在页面之外连续处理并断点续跑时,使用 [`orbit-admin retention archive`](retention-jobs.md)。目录、批量、总字节及执行时限可配置,已发布文件就是恢复检查点,支持离线核验;源业务数据仍保留。周期触发和清理执行器尚未接入。
## 只读盘点
`orbit-admin retention inventory` 用于在制定保留窗口、归档和备份恢复方案前盘点历史数据。它只执行带截止时间的统计查询,不删除、搬迁、改写数据,也不会自动判断某条记录可以安全归档。

View File

@@ -9,8 +9,8 @@ http://127.0.0.1:8080/console/
## 当前能力
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
- 通过用户选择的本地凭据 JSON 或手工 Token/队列范围调用现有授权 API;队列范围优先从凭据 JSON 或服务端默认配置自动填充。
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;连接参数收进“连接设置”抽屉,不占用统计区域。
- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
@@ -20,30 +20,60 @@ http://127.0.0.1:8080/console/
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
- 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
- 左侧六个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows`、`#/subscriptions` 和 `#/retention` 定位;切换视图不会把所有模块堆在一张长页面上。
- “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。
- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
## 本地打开
启动 Orbit 后直接访问 `/console/`。当前 Docker 开发环境可用:
启动 Orbit 后访问 `/console/`。未登录时,任何控制台业务页面都会跳转到 `/console/login`;登录成功后进入控制台,登录会话过期或服务端返回 401 时也会重新跳转。当前 Docker 开发环境入口:
```text
http://127.0.0.1:8080/console/
```
启动 Orbit 后,首页先展示运行统计;需要切换环境或加载凭据时,点击顶部齿轮、首页“连接设置”或统计旁的设置入口。API 地址默认使用当前控制台页面所在的地址,不需要手工填写。点击“选择凭据 JSON”,选择 `orbit-admin bootstrap` 生成的私有凭据文件,例如 `.local/local.credentials.json`,队列范围和 Token 会自动读取,再点击“连接”。浏览器不能在没有用户选择的情况下读取本机文件,这是浏览器的安全边界;文件只在当前页面内解析,Token 不会上传到 Orbit Console,也不写入 Orbit 数据库。
首次使用可以在 `orbit-admin bootstrap` 时一起创建控制台管理员。这样业务资源、业务凭据和管理员账号在同一个初始化事务中提交:
如果一个部署只服务一个默认队列,可以在服务端配置队列范围,页面打开后会自动使用它:
```sh
secret-manager read orbit/console-admin-password | \
./bin/orbit-admin bootstrap \
--application sample-app \
--namespace default \
--environment production \
--queue files \
--console-username admin \
--console-display-name 'Orbit 管理员' \
--console-password-stdin \
--output .local/production.credentials.json
```
密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
```sh
./bin/orbit-admin console-user create \
--credential-file .local/local.credentials.json \
--username operator \
--display-name 'Orbit 管理员' \
--password-stdin
```
命令会把密码写成 bcrypt 哈希,只输出账号的脱敏信息。生产环境应通过受保护的密钥管道提供标准输入;不要把密码写进命令历史、镜像或仓库。账号创建后直接打开控制台,用账号密码登录。
页面的 API 地址默认自动取当前访问地址的 origin,不需要手填。部署到域名后,直接访问 `https://orbit.example.com/console/`,页面会自动请求同一域名下的服务端接口;生产建议用反向代理让控制台和 API 保持同源。页面不再要求填写队列 ID、Bearer Token,也不再选择凭据 JSON。
如果未来把前端静态资源和服务端拆成不同域名,需要额外设计 API 基地址、CORS、Cookie `SameSite`/Domain 和 CSRF 防护,不能只替换一个 URL。当前内置控制台优先保证同源部署的安全边界。
`ORBIT_CONSOLE_QUEUE_ID` 只作为单一默认队列的展示和查询范围配置:
```sh
export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
```
`GET /console/config` 只返回这个非敏感的默认队列 ID,不返回 Token、不读取数据库。凭据 JSON 中的 `queue_id` 优先级更高,可以覆盖默认值。没有队列配置时仍可查看健康和版本;读取任务、计划、流程及订阅需要同时提供 Bearer Token 和队列范围。平台不会根据 Token 猜测队列,因为一个 Token 可能被授予多个队列,自动猜测会造成数据范围错误。
`GET /console/config` 只对已登录会话返回这个非敏感的默认队列 ID。健康检查和版本探针保持公开,业务 API、控制台 HTML、配置和所有业务模块均受登录会话与原有权限双重保护。
## 安全边界
控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。
控制台不直接访问数据库,也不绕过业务凭据权限。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台账号绑定业务凭据,不自动获得跨命名空间权限;不支持跨命名空间聚合,不代替平台的权限与审计 API。
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。

View File

@@ -18,6 +18,10 @@
独立的流程场景另见[有界轮询](workflow-poll.md)与[证据恢复](workflow-recovery.md):前者区分请求重试与业务轮次,后者实际注入外部提交后HTTP断线,保留UNKNOWN历史和暂停门控。本机渠道是受控夹具,不能代替持久业务证据。
## 已可运行:嵌入现有 HTTP 服务
[完整 HTTP 工程与命令](http-service.md):业务接口同事务入队 → 后台发送 → 内嵌 Worker → WebSocket → Inbox,包含健康检查、后台错误传播、SIGTERM收尾和SIGKILL恢复。公共包接口见 [MySQL Outbox](../sdk/outbox-mysql.md)。
## 已可运行:业务事务与任务提交Outbox
- 独立模块:[task-outbox](../../examples/task-outbox/README.md),[完整前置、命令、Go方法及故障处理](task-outbox.md)。

View File

@@ -0,0 +1,129 @@
# HTTP 服务完整接入示例
[源码工程](../../examples/http-service/README.md)将 SDK 的发送器、Worker、结果消费器放进业务 HTTP 进程。Orbit 平台仍然独立部署,业务表放在自己的 MySQL 数据库。
## 1. 场景与运行结果
客户端用固定 operation_id 提交一个对象引用。HTTP 事务同时写入业务行和待发请求,后台发送给 Orbit。Worker 对引用进行纯计算转换,结果通过可靠 WebSocket 订阅写入业务 Inbox,再由 SDK 确认交付。
接口返回 202 表示业务本地事务已提交。查询先看到 PENDING/SENDING,随后 ACCEPTED,最后收到 execution=SUCCEEDED 和转换结果。每个阶段以数据库记录为准,HTTP 请求不等待远端任务执行结束。
## 2. 准备配置
需要 Go 1.27.1、运行中的 Orbit 和 MySQL 8.4。创建一个独立的业务数据库,例如 orbit_http_demo,不使用 Orbit 平台库。由业务迁移账号初始化表,正常运行账号只授予所需业务表读写权限。
在仓库根目录创建示例专用应用/队列凭据,下面的 ORBIT_MYSQL_DSN 是**平台库**:
```sh
umask 077
mkdir -p .local
export ORBIT_MYSQL_DSN='平台数据库DSN'
go run ./cmd/orbit-admin bootstrap \
--application http-demo --namespace default --environment development \
--queue http-demo --worker-access --event-access \
--output .local/http-demo.credentials.json
```
该命令用于一次性准备专用队列;重启复用原凭据,不重新 bootstrap。凭据具有任务提交、队列读取、Worker 和事件所需权限,生产可按角色拆分身份。
```sh
# 在仓库根目录保存凭据绝对路径,再进入示例。
export ORBIT_CREDENTIALS_FILE="$PWD/.local/http-demo.credentials.json"
export ORBIT_URL='http://127.0.0.1:8080'
export APP_MYSQL_DSN='业务账号:密码@tcp(127.0.0.1:3306)/orbit_http_demo'
export APP_HTTP_ADDR='127.0.0.1:8090'
cd examples/http-service
export GOWORK=off
go run ./cmd/server init
export ORBIT_SUBSCRIPTION_ID="$(go run ./cmd/server provision)"
go build -o ./server ./cmd/server
./server serve
```
`init` 仅首次在空业务库执行,不自动升级或覆盖已有表;生产迁移管理四张表:sender_outbox、sender_jobs、receiver_inbox、receiver_task_views。全部字段有中文注释,没有外键。
`provision` 在发送任何业务任务前执行,使用固定键和固定内容创建订阅。将输出的订阅 ID 保存在部署配置中,后续启动只需 `serve`,不依赖 Orbit 在线才能接收本地业务请求。运行凭据必须能消费该订阅的绑定身份,轮换按原凭据规则执行。
也可用 ORBIT_TOKEN、ORBIT_NAMESPACE_ID、ORBIT_QUEUE_ID 配置代替文件;环境变量优先。APP_HTTP_ADDR 仅接受回环 IP,示例不提供对公网的账号体系。集成到现有 HTTP 服务时复用已有认证与授权中间件,不要直接取消绑定限制暴露演示路由。
## 3. HTTP 接口与调用
| 接口 | 返回 |
| --- | --- |
| `GET /health/live` | 进程存活:200 |
| `GET /health/ready` | 正在接收请求且业务数据库可用:200;关闭期间/数据库不可用:503 |
| `POST /jobs` | 保存业务事务,202;字段错误400、原操作内容冲突409、存储/提交不明503 |
| `GET /jobs/{operation_id}` | 业务提交与结果视图200;不存在404、存储错误503 |
```sh
export OPERATION_ID="$(openssl rand -hex 16)"
curl -sS http://127.0.0.1:8090/jobs \
-H 'Content-Type: application/json' \
-d "{\"operation_id\":\"$OPERATION_ID\",\"source_ref\":\"objects/demo.txt\"}"
curl -sS "http://127.0.0.1:8090/jobs/$OPERATION_ID"
```
首次提交:
```json
{"operation_id":"原操作ID","local_committed":true,"duplicate":false}
```
等待结果入库后查询:
```json
{
"operation_id":"原操作ID",
"submission":"ACCEPTED",
"code":"ACCEPTED",
"task_id":"平台任务ID",
"execution":"SUCCEEDED",
"result":{"source_ref":"objects/demo.txt","normalized_ref":"OBJECTS/DEMO.TXT"}
}
```
用同一个 OPERATION_ID 和相同请求重复 POST,返回 duplicate=true,不重复写业务行或创建平台任务。相同 ID 改 source_ref 返回409。客户端超时/503时保留原 ID、原内容重试,不重新生成随机 ID。source_ref 最多512个Unicode字符,不能空白或包含控制字符;接口正文上限4KiB。
## 4. 生命周期
[生命周期源码](../../examples/http-service/cmd/server/lifecycle.go)包含以下顺序:
1. 加载配置、连接业务库、核验表、构造 SDK 组件,再创建 HTTP 监听。任何构造/监听失败都关闭已经持有的资源;不会先启动失去宿主管理的 Worker。
2. 启动 HTTP、发送器、Worker 和结果消费器;各组件使用独立的宿主 context,进程信号只触发统一关闭流程。
3. HTTP 监听失败或任何后台组件不可恢复退出,撤销 readiness,进入同一关闭路径,错误返回 main 并以非零状态退出。
4. 收到 SIGTERM/SIGINT 后撤销 readiness,用**新的有期限 context**调用 HTTP Shutdown,等待已进入的业务请求完成;共享数据库及后台组件此时仍可用。
5. HTTP 结束后取消发送器和 Worker,等待当前发送确认、在途任务续租及结果上报完成。
6. 最后取消结果消费器,等待处理器/ACK 有界收尾,随后关闭共享数据库连接池。
发送器和 Worker 收尾各20s,结果消费器10s;示例每个关闭阶段最大25s,进程平台的终止宽限应大于三个阶段之和,例如90s。超时返回错误,不声称工作全部完成。忽略 context 的自定义处理器不能被 Go 强杀;实际业务必须遵守取消,并为外部副作用建立幂等/查询核对。
消费器取消会取消其处理器;事务未确认时不会 ACK,重启后允许重投。关闭前并不要求全局订阅积压归零,进程退出后未消费结果仍由持久订阅保存。示例 Inbox 按事件 ID 去重,按任务版本防止迟到覆盖,提交回执未知返回错误,不能提前确认交付。
readiness 表示能安全接受**本地持久请求**,不要求 Orbit 或 WebSocket 始终在线;Orbit暂时不可用时任务留在 Outbox。若某类业务必须同步依赖远端,应单独增加路由级就绪门槛。监控仍需覆盖 Outbox 积压/最老待发年龄/REVIEW、Worker重连和事件积压,不能只看健康接口。
## 5. 故障场景
| 场景 | 预期行为 |
| --- | --- |
| Orbit 暂时不可用 | HTTP仍可保存业务事务,Outbox有界退避;受理超出24h/100次进入REVIEW |
| HTTP提交响应丢失 | 客户端按原ID与内容核对,业务行与待发请求不重复 |
| Orbit受理响应丢失 | 发送器保留原提交键重新核对,不能换键 |
| 本地业务/确认COMMIT不明 | 保留原身份,不把失败返回当成数据库回滚证明 |
| 进程SIGKILL | 内存丢失;重启用相同数据库、目标、凭据和订阅ID恢复,已有发送租约自然到期 |
| 正常SIGTERM | 按上述顺序收尾;成功退出码0,异常/超时非零 |
| 结果重复投递 | 同业务事务Inbox去重,提交完成后才ACK |
不要把示例的纯计算 `RecoveryIdempotent` 直接用于支付、发送通知等外部效果。真实业务要实现自己的幂等或查询核对。任务预算、24h发送期限、结果交付预算保持有限,长时间停机可能需要人工核查。
## 6. 验证与接入现有框架
```sh
# 仓库根目录:脚本自行创建、销毁专用MySQL,不操作本地开发库。
make adapters-check
make test vet build docs-check sdk-check
make integration
```
[验证记录](../reports/outbox-http-integration.md)区分纯生命周期测试和真实数据库/平台/进程证据。Gin、Echo、go-zero 等服务可复用本例的组件装配和关闭顺序,将业务路由挂到原路由器,统一接入原生命周期;不需要另起一套业务 HTTP 服务,也不在每次请求里创建 Worker。

View File

@@ -4,7 +4,7 @@
## 1. 数据与状态
[中文模型](../../examples/task-outbox/internal/outbox/model.go)和[SQL](../../examples/task-outbox/internal/outbox/schema.sql)包含2张业务表、22个字段,全部有中文说明,外键为零,不进入Orbit平台迁移。
[中文模型](../../examples/task-outbox/internal/outbox/model.go)和[SQL](../../examples/task-outbox/internal/outbox/schema.sql)与[公共 Outbox SQL](../../adapters/outbox/mysql/schema.sql)合计包含2张业务表、22个字段,全部有中文说明,外键为零,不进入Orbit平台迁移。
| 表 | 职责与关键约束 |
| --- | --- |
@@ -103,7 +103,7 @@ GOWORK=off go run ./cmd/sender status "$SENDER_OPERATION_ID"
## 4. Go接入接口
以下方法属于示例的内部业务适配包,完整源码见[store.go](../../examples/task-outbox/internal/outbox/store.go)及[sender.go](../../examples/task-outbox/internal/outbox/sender.go)。接入自己的业务模块时保留事务顺序与协议,替换sender_jobs为实际业务表;核心Orbit客户端仍使用独立SDK。
发送与租约实现已抽至[公共适配包](../sdk/outbox-mysql.md)。以下方法是本示例的业务封装,完整源码见[store.go](../../examples/task-outbox/internal/outbox/store.go)及[sender.go](../../adapters/outbox/mysql/sender.go)。接入自己的业务模块时保留事务顺序与协议,替换sender_jobs为实际业务表;核心Orbit客户端仍使用独立SDK。
```go
func Open(ctx context.Context, dsn string) (*sql.DB, error)

View File

@@ -45,7 +45,7 @@
| 受理、派发、流程推进和恢复吞吐 | 固定硬件、数据量、任务/流程混合模型,p50/p95/p99和资源曲线 | 已有受理样本、[真实Worker短/混合/中长执行样本](reports/phase-6-execution-load.md)、[Weave双分支流程样本](reports/phase-6-workflow-load.md)、[定时集中触发/积压曲线样本](reports/phase-6-backlog.md)及本轮[四项闭环验收](reports/phase-7-acceptance-20260921.md);目标硬件资源曲线、生产容量和HA仍未验收 |
| 100万+企业规模推导 | [容量模型](19-capacity-model.md):租户、队列、连接、事件、索引和保存周期的参数化推导 | 已建立计算方法和演算样例;目标业务数据、硬件、拓扑和生产容量仍未验收,不能由企业数直接承诺 |
| MySQL 复制、切换、RPO/RTO | [隔离 GTID 复制与手工提升演练](reports/phase-6-mysql-replication.md);目标拓扑上的实际切换、复制确认、恢复点和恢复时间 | 隔离源库/副本复制、手工提升和原幂等键重放已通过;自动切换、目标拓扑、RPO/RTO仍未验收 |
| 慢消费者、重连风暴、磁盘和锁等待 | 受控故障负载及积压清空证据 | SDK 受控连续断线和真实 HTTP/WebSocket 容量1慢消费者已回归(见[重连报告](reports/phase-6-reconnect-storm.md)、[慢消费者报告](reports/phase-6-slow-consumer.md));跨节点风暴、磁盘和锁等待未验收 |
| 慢消费者、重连风暴、磁盘和锁等待 | `make stability-check`,受控故障负载及积压清空证据 | SDK 受控连续断线、跨节点重连风暴、真实 HTTP/WebSocket 容量1慢消费者、真实 MySQL 锁等待/死锁和保留期磁盘增长已加入定向回归;目标生产拓扑的规模和告警阈值仍待验收 |
| 跨版本 SDK/服务端 | 至少一个正式旧标签与候选版本的双向矩阵 | 历史提交 `a50d5fa` 的稳定任务 API 双向 smoke 已通过(见[报告](reports/phase-7-historical-compatibility.md));正式标签和全协议矩阵仍未验收 |
发布候选只有在所有“待目标环境/未验收”项得到对应环境证据后,才能进入生产审批。当前代码和阶段回归不等同于生产高可用、容量、RPO/RTO或百万企业承诺。

View File

@@ -0,0 +1,44 @@
# 可复用 Outbox 与 HTTP 生命周期验证
日期:2026-09-24。本报告仅覆盖本批接入适配与示例,不替代平台目标生产环境容量、高可用及数据库复制确认验收。
## 交付
- 独立模块 adapters/outbox/mysql,公开 EnqueueTx、可选受理关联回调、Run 和查询接口。无平台内部依赖,核心 SDK 无 SQL 驱动。
- 旧 CLI 的发送核心及原有故障测试迁入公共模块;旧业务表、命令和固定提交身份兼容。
- examples/http-service 提供完整装配、业务路由、数据库事务、Worker、持久 Inbox、后台错误传播及有界关闭。
- 业务库与平台库分离;SQL沿用无外键表、中文字段注释。平台迁移及SDK协议未修改。
## 验收场景
1. 业务回滚同时撤销待发记录;未提交的记录不能领取;调用方负责 Commit。
2. 8 个并发重复、同事务重复、RR 旧快照核对;内容/目标冲突拒绝;大整数保真。
3. 接受钩子失败整笔确认回滚;无钩子也可确认,不依赖示例业务表;旧租约代次不能覆盖新确认。
4. 原HTTP回执丢失、三个阶段的真实MySQL COMMIT成功包丢失、平台受理后发送器SIGKILL及自然租约接管。
5. Run取消时等待在途发送完成确认,超时返回错误并保留持久恢复状态。
6. HTTP请求未结束时后台依赖保持可用;后台致命错误触发宿主关闭;消费器在发送/执行组件之后结束。
7. 完整进程:代理令Orbit不可用,HTTP仍提交本地事务;SIGKILL旧宿主并确认退出;新进程恢复原操作,实际Worker执行、Inbox入库、平台ACK;重复HTTP不新建业务行,改内容409,SIGTERM退出0。
## 验证状态
环境:macOS arm64、Go 1.27.1、Docker隔离MySQL 8.4、独立业务库与Orbit平台进程,回环地址;业务与平台使用同一隔离MySQL实例,不代表跨故障域部署。
- `make test`:六个模块的标准竞态测试通过(未配置数据库时Integration会跳过,真实数据库证据另列)。
- `make vet build sdk-check`:通过;SDK 30个包、禁止依赖0项。适配器在GOWORK=off下使用已发布SDK v0.1.0,依赖图无平台内部包。
- `make docs-check`:166份Markdown的本地链接检查通过。
- `make adapters-check`:隔离MySQL/平台/业务模块定向回归退出0;公共Outbox 33.792s,完整HTTP进程场景9.52s,全部通过。恢复测试真实等待旧发送租约自然到期。
- `make integration`:全量平台回归退出0;包含真实存储/升级、SDK、旧CLI、公共Outbox、HTTP宿主及Worker/Event/Workflow进程恢复。全量链路中Outbox 33.934s,HTTP进程场景9.64s,新增同事务重复与RR核对均通过。
复现:`make adapters-check`(定向隔离链路)和 `make integration`(平台全量隔离链路)。不操作现有开发数据库或运行容器。
## 本批发现与修正
新增发送器关闭测试的初版 HTTP 桩缺失协议必填 request_id,SDK正确拒绝无效响应,使测试未进入提交阶段。补齐符合正式协议的响应后重新执行。随后发现超时路径的测试桩仍持有未释放屏障,导致 httptest.Close 等待;已在清理中显式、幂等释放屏障并重跑。两次未通过运行均不计入通过证据。
## 边界
- 首版适配器仅支持 MySQL、SubmitTask;不会自动写业务表、删除历史或自动解除 REVIEW。
- 外部副作用仍需业务幂等/核对;示例 Worker 仅做可重复纯计算。
- 每条记录沿用24小时/100次总发送预算,重启不延长。ACCEPTED仅为平台受理。
- HTTP示例只监听回环地址;接入真实服务复用业务自身鉴权、监控和部署生命周期。健康就绪不等于全局任务已经执行完毕。
- Inbox是示例业务实现,不是本批新增的公共适配包;其他数据库/ORM专用集成不在本批交付内。

View File

@@ -0,0 +1,40 @@
# HTTPS/WSS 入口基线验证
验证日期:2026-09-22
验证范围:Orbit 主机端口边界、Nginx HTTPS 配置语法、WebSocket Upgrade 路由、健康探针和控制台登录限速配置。
验证环境:Docker Desktop、Nginx 1.27 Alpine、当前 Compose 配置。
## 已通过
### Orbit 端口边界
使用临时数据库变量展开 Compose 配置,确认默认映射为:
```text
host_ip: 127.0.0.1
published: "8080"
target: 8080
```
Orbit 的明文业务端口默认只对本机开放,公网入口由反向代理承担。需要直接暴露端口的隔离环境必须显式设置 `ORBIT_HTTP_BIND`,不能把开发配置直接当作生产入口。
### Nginx 配置语法
`deploy/nginx/orbit.conf.example` 使用一次性自签名证书挂载到 Nginx 1.27 Alpine 容器中执行:
```sh
nginx -t
```
结果:
```text
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
配置包含 HTTP 到 HTTPS 跳转、TLS 1.2/1.3、`/v1/worker/connect` 和 `/v1/events/connect` 的 Upgrade/Connection 头、独立长连接超时、`/health/live`、`/health/ready` 代理以及 Console 登录来源限速。metrics 不经过业务入口。
## 尚未由本报告关闭的事项
本报告只验证配置和端口边界,没有使用真实域名、正式证书、外部负载均衡或公网流量。生产仍需验证证书签发/轮换、DNS、WSS 长连接重连、代理节点故障、真实健康摘除和入口访问日志脱敏;这些不能由一次 `nginx -t` 推导出来。

View File

@@ -0,0 +1,42 @@
# 两节点 Compose 覆盖验证
验证日期:2026-09-22
验证范围:两个 Orbit 节点共享 MySQL、独立节点就绪、停止单节点后的对等节点可用、停止节点重新加入。
验证环境:Docker Desktop、MySQL 8.4、当前 `orbit:dev` 镜像、Go 1.27.1 构建产物。
## 验证入口
```sh
docker compose \
-f deploy/docker-compose.yml \
-f deploy/docker-compose.ha.yml \
up -d mysql verify orbit-a orbit-b
```
本机已有大量 Docker 自定义网络,本次只在临时测试覆盖文件中使用默认 `bridge` 网络和 MySQL 链接,以避开本机地址池耗尽;正式的 `deploy/docker-compose.ha.yml` 保持 Compose 项目隔离网络。
## 结果
一次性 MySQL 空库完成健康检查、迁移、权限收紧和 schema 核验后,两个 Orbit 节点均启动并通过就绪探针:
```text
ha-e2e=both_ready
```
停止 `orbit-a` 后,`orbit-b` 继续返回就绪:
```text
ha-e2e=peer_ready_after_orbit_a_stop
```
重新拉起 `orbit-a`,它重新完成迁移/核验依赖并恢复就绪:
```text
ha-e2e=orbit_a_rejoined
```
两节点分别绑定临时主机端口 `18087` 和 `18088`;正式入口由 Nginx upstream 或负载均衡器同时指向两个节点,并以 `/health/ready` 作为摘流条件。
## 验收边界
该回归证明节点无本地权威状态、停止/重加入顺序和 Compose 依赖正确;它没有证明数据库自动切换、跨故障域、负载均衡器故障、生产证书轮换、容量余量或 RPO/RTO。任务执行和租约接管仍需在目标拓扑中按真实 Worker、慢消费者和数据库故障场景留证。

View File

@@ -7,13 +7,13 @@
- [独立模块](../../examples/task-outbox/README.md)只依赖独立Orbit SDK与业务侧MySQL驱动,核心SDK没有新增依赖。
- [模型](../../examples/task-outbox/internal/outbox/model.go)与[建表](../../examples/task-outbox/internal/outbox/schema.sql):2个业务实体、22个Go/SQL中文注释字段,零外键;不改变平台35个实体和289个持久化字段。
- [业务事务](../../examples/task-outbox/internal/outbox/store.go):业务操作与待发命令原子创建,稳定操作/提交身份、冻结请求、摘要、有限次数、租约代次及原回执;确认与业务任务关联再次同事务。
- [发送器](../../examples/task-outbox/internal/outbox/sender.go):事务外HTTP、当前凭据核对目标、原键重报、背压退避、未知结果分类、过期接管与旧代次拒绝。
- [发送器](../../adapters/outbox/mysql/sender.go):事务外HTTP、当前凭据核对目标、原键重报、背压退避、未知结果分类、过期接管与旧代次拒绝。
- [CLI](../../examples/task-outbox/cmd/sender/main.go)及[完整命令/方法/恢复手册](../examples/task-outbox.md):init、enqueue、send、status和demo。
- [Makefile](../../Makefile)和[隔离脚本](../../scripts/test-mysql.sh)纳入独立模块,使用单独业务数据库和专用平台凭据,不触碰现有开发数据库。
## 2. 定向数据库与故障证据
[数据库回归](../../examples/task-outbox/internal/outbox/store_integration_test.go)验证:
[数据库回归](../../adapters/outbox/mysql/store_integration_test.go)验证:
1. 8个相同业务操作并发入库只有一条业务记录/Outbox,7次重复核对;修改正文、源引用或目标队列不能复用原操作。
2. 实际业务INSERT失败时Outbox一起回滚;业务关联UPDATE失败时ACCEPTED一起回滚,恢复后可保存原回执。
@@ -21,13 +21,13 @@
4. 错误目标、损坏文档、权限拒绝、幂等冲突和无效回执停止自动发送;背压遵守Retry-After,次数耗尽和截止产生REVIEW,最后一次未知响应不能绕过上限。
5. 实际Schema查询确认22个中文列注释、外键0,通过实际违规写入验证JSON、状态、次数和截止CHECK生效,领取查询使用ix_sender_due且无filesort;大整数9007199254740993在冻结请求与真实回执中保持精确。
[COMMIT故障代理](../../examples/task-outbox/internal/outbox/commit_loss_integration_test.go)在业务入库、领取和本地确认三处分别截获真实MySQL COMMIT OK包后丢弃。调用方得到ErrCommitUnconfirmed,新Store分别核对已存在业务记录、已提交租约和已确认关联,不假定COMMIT失败即回滚。
[COMMIT故障代理](../../adapters/outbox/mysql/commit_loss_integration_test.go)在业务入库、领取和本地确认三处分别截获真实MySQL COMMIT OK包后丢弃。调用方得到ErrCommitUnconfirmed,新Store分别核对已存在业务记录、已提交租约和已确认关联,不假定COMMIT失败即回滚。
[HTTP回执丢失](../../examples/task-outbox/internal/outbox/http_integration_test.go)先取得真实平台受理结果,再向客户端发送不完整响应并断开;Outbox保持PENDING,原键重报后同一任务ACCEPTED、deduplicated=true、发送次数2,队列只有一项任务。错误持久化命名空间在提交前被拒绝。
[HTTP回执丢失](../../adapters/outbox/mysql/http_integration_test.go)先取得真实平台受理结果,再向客户端发送不完整响应并断开;Outbox保持PENDING,原键重报后同一任务ACCEPTED、deduplicated=true、发送次数2,队列只有一项任务。错误持久化命名空间在提交前被拒绝。
[发送进程故障](../../examples/task-outbox/internal/outbox/process_integration_test.go)等待真实平台受理后、本地确认前的屏障,再SIGKILL并确认旧进程已退出。新进程等待数据库30秒租约自然到期,以原键取得原回执;最终只存在一个平台任务和一条本地业务记录。测试没有直接改时间来替代这项进程接管。
[发送进程故障](../../adapters/outbox/mysql/process_integration_test.go)等待真实平台受理后、本地确认前的屏障,再SIGKILL并确认旧进程已退出。新进程等待数据库30秒租约自然到期,以原键取得原回执;最终只存在一个平台任务和一条本地业务记录。测试没有直接改时间来替代这项进程接管。
定向脚本第三轮正常退出,状态码0:Outbox集成33.009秒,其中发送进程故障31.19秒;init/demo均正常完成。随后增加的[真实CLI测试](../../examples/task-outbox/internal/outbox/cli_integration_test.go)进入本轮全量回归,验证编译后的enqueue重复、status、send及正常信号停止。
定向脚本第三轮正常退出,状态码0:Outbox集成33.009秒,其中发送进程故障31.19秒;init/demo均正常完成。随后增加的[真实CLI测试](../../adapters/outbox/mysql/cli_integration_test.go)进入本轮全量回归,验证编译后的enqueue重复、status、send及正常信号停止。
## 3. 发现与修正
@@ -73,3 +73,5 @@ make integration
- REVIEW保留原数据,由接入方带证据和审计的管理流程处置;本示例未实现通用业务审核系统或跨业务ORM适配。
- 核心SDK、平台Schema和执行协议均未改变;新增业务示例不是已发布的兼容性SDK包。
- 流程启动与信号Outbox类型需随Weave API实现;P6继续容量、数据库切换、备份恢复及运维验证。当前正确性与进程恢复证据不等同于生产高可用或百万规模容量结论。
2026-09-24 源码组织调整:发送核心及原故障回归迁至独立 adapters/outbox/mysql 模块,业务示例调用公共包;Outbox Record/SQL见[公共模型](../../adapters/outbox/mysql/model.go)与[表结构](../../adapters/outbox/mysql/schema.sql),sender_jobs 保留在旧业务示例。上述历史验证日期不变,新增验证另见[接入适配报告](outbox-http-integration.md)。

View File

@@ -0,0 +1,49 @@
# P6:周期归档、持久作业与管理页面
日期:2026-09-23。变更基于 `6eb429e`,新增 Schema 31。范围为通用周期归档,不包含源数据删除。
## 交付内容
- 每个命名空间七类独立周期计划,保存策略版本、卷身份、周期、批大小、容量、触发时间和未结清作业占位。
- 作业状态与随机租约持久化;周期错过合并一次;失败占位,管理员可原作业重试或关闭。变更、人工操作、作业受理和终态审计同事务。
- 卷身份文件、全卷锁与作业锁;每轮一个文件批次;固定作业身份,验证文件链与数据库已确认前缀后继续。文件先发布后确认,提交不明时保留原状态恢复。
- 单作业和全卷逻辑文件容量上限,目录按 256 个条目分块扫描,最多 100000 项;不删除文件腾空间。
- 独立「归档作业」页面:周期表单、七类启停概况、本页待处理与根记录数、作业分页、校验摘要、失败解释、原作业恢复/关闭。沿用单管理员登录。
- [部署、接口与恢复操作示例](../api/archive-scheduler.md);文件格式与独立 CLI 兼容。
## 验证证据
| 场景 | 实际验证 |
| --- | --- |
| 并发触发 | 真实 MySQL,8 个独立存储调用竞争同一到期计划,仅生成 1 个作业 |
| 并发领取 | 8 个竞争者仅一个有效租约;过期后换代领取,原代次确认返回状态冲突 |
| 文件/数据库确认间隙 | 首批 2 条审计已持久化,尚未确认数据库;控制测试将租约设为过期,新代次确认文件前缀,再完成末批,最终 2 批/3 条、同一 ID,离线摘要一致 |
| 文件损坏 | 损坏首批后停止,数据库保留原确认的批数与摘要;恢复原字节后以原 ID 重试,重复操作只留一条重试审计 |
| 策略变化 | 修改保留窗口后旧作业以 POLICY_CHANGED 停止;旧计划不再触发,关闭旧作业并确认新策略版本后才创建后续作业 |
| 暂停周期 | 已在执行的作业可正常结清,停止新周期触发 |
| 容量与分页 | 3 条各含 600000 字节正文的真实审计;1 MiB 预算停止,提高到 4 MiB 后原作业完成;独立全卷预算阻止新输出;26 个作业分 20+6 两页,无重复 |
| 文件与卷约束 | 单测覆盖数据库链前缀不符、原目录缺失、卷身份不符、初始化临时文件恢复、另一节点持锁时启动、忙卷不调度、目录分块计数与符号链接拒绝 |
| 身份与来源限制 | 未登录、只有 Bearer、跨命名空间和跨来源写操作拒绝;普通列表不返回租约代次 |
| 容器端到端 | 隔离 MySQL + Orbit 镜像,管理员登录、策略保存、周期保存/原键重放、后台生成归档、SUCCEEDED 和摘要、登出后 401;原 Worker/重试/计划及独立离线归档核验仍通过 |
| 浏览器实际操作 | 隔离测试账号从页面选择审计类别、保存周期,自动完成 2 批/3 条;制造 1 MiB 容量失败,页面填写原因提高到 4 MiB,原作业完成 3 批/5 条;失败计数从1回到0,旧周期结果保留 |
| 数据模型 | 当前基线与前向迁移无外键;历史升级保留原数据和既有索引;归档后源审计仍完整 |
本批覆盖的是数据库竞争、受控过期与文件确认间隙,不把修改测试租约截止等同于生产等待五分钟或真实跨机房切换。独立 CLI 的实际 SIGKILL 恢复证据见[文件归档报告](phase-6-retention-jobs.md)。
## 回归入口
- `make test vet build docs-check`:通过。
- `make console-e2e`:通过;首次运行发现 HTTP 路由范围冲突,修复后重跑通过,已加入路由注册/认证/跨来源回归。
- `go test -race -count=1 -run '^TestIntegrationArchive' -v ./internal/storage/mysql`(隔离数据库):3 个专项全部通过;最终目录分块扫描实现再次通过此回归。
- 最终文件执行器、配置和 HTTP 包的 `go test -race`:通过。
- `make integration`:通过。存储全量 305.578 秒,最终 SDK 进程故障组 132.167 秒、流程暂停全停组 12.906 秒;旧 Worker、订阅、调度、流程和两个独立业务模块回归通过。
新增容量夹具最初使用 UPDATE 填充旧审计,被已有 append-only 触发器拒绝;已修正为插入时写入大正文,未放宽审计不可改写约束。
## 当前边界
- 每次周期是新的候选快照,跨作业可能重复归档同一源记录。只有作业内分页固定截止和位置。
- 保留策略调整不会自动改变旧作业;关闭失败作业保留全部文件和历史。
- 首份清单写入前中断时,恢复不会重建缺失目录,需核查后关闭旧作业再生成新作业。
- 根目录物理空间、备份介质、对象存储与源库清理不在本批交付内;容量上限按逻辑文件字节计,不替代文件系统配额。
- 同卷接管要求所有节点访问同一支持所需 POSIX 语义的物理卷。隔离测试和本机 Docker 结果不代表任意网络文件系统或目标生产跨故障域验收。

View File

@@ -0,0 +1,43 @@
# 持久文件归档作业回归(2026-09-23)
本批在 `2d8467e` 的只读归档导出之上增加本地持久作业执行器及运维 CLI。无数据库迁移、外键或源数据清理操作;控制台导出的授权方式不变。使用说明与参数见[文件归档作业](../api/retention-jobs.md)。
环境:macOS arm64、Go 1.27.1、本地 POSIX 文件系统;Docker MySQL 8.4 与 Linux 容器。证据仅覆盖隔离环境中的文件恢复和数据库行为,不代表共享存储或跨节点归档高可用已经验收。
## 文件与进程恢复
`go test -race ./internal/retention ./cmd/orbit-admin` 覆盖:
| 场景 | 核对内容 |
| --- | --- |
| 有限批次与续跑 | 首次仅一批,保留原作业 ID;下次仅导出第二批;完成后重复调用不重复导出 |
| 崩溃残留 | 部分 `.write.tmp` 不算检查点,恢复会重新导出未发布批次 |
| 真实进程强杀 | 子进程第一批落盘后发出就绪标记,父进程 Kill 并 Wait 后,新进程取得锁并只继续第二批 |
| 文件链损坏 | 正文改动、缺批、互换、符号链接、未知文件全部阻止核验与续跑 |
| 容量上限 | 第一批已保存,第二批超过 1 MiB 总额时停止;显式提高预算后继续,第一批原字节不变 |
| 写入失败 | 在非 root 进程中把目录改为只读,第二批写入失败但原检查点仍可核验;恢复写权限后续跑 |
| 取消与并发 | 批次返回后上下文取消不发布文件;相同目录第二个写者被非阻塞文件锁拒绝 |
| 独立可信摘要 | 无 DSN 也能校验;与另行保存的末端摘要不匹配时非零退出 |
每个批次先同步正文,再原子发布、同步目录并回读。文件权限为 0600,目录为 0700。SHA-256 和前序摘要用于完整性校验;没有独立可信末端摘要时,不声称能抵抗整套档案与摘要一起被重写。
## Docker 首次部署闭环
`make console-e2e` 从空库迁移、管理员初始化和登录开始,保留原任务/重试/计划/退出后拒绝访问验证,增加:
- 使用刚保存的保留策略生成一个合法空作业,`completed=true`、`batches=1`、`roots=0`。
- 在 `--network none` 的独立容器运行 `retention verify-archive`,不注入 DSN;结果与运行输出逐字段一致。
数据库提交后的导出审计与文件持久化不是同一事务。文件失败可能导致重试产生新的导出审计;不会改写源记录,不能以审计存在推断文件已经保存。
## 边界
本批交付可重复执行的有限批次执行器,不新增周期调度服务或控制台作业列表。本地同目录文件锁不能证明跨节点共享文件系统可靠;不同节点独立磁盘也不能互相恢复。新一轮扫描需要独立目录,归档介质备份、定时触发、长期容量、跨节点接管及清理执行器仍需后续实现和验收。
## 真实 MySQL 作业结果
标准集成入口中的 `TestIntegrationRetentionDiskGrowthAndPreview` 使用 256 条样本,其中 254 条终态且完成时间相同,另 2 条为未知效果/运行租约保护对象。首次文件作业只导出 37 条,下一次从原目录接续,最终 254 条根记录、7 个批次、7 条 `archive:<job_id>` 审计;离线核验返回相同摘要和计数。停用命名空间返回 `PERMISSION_DENIED`,不能继续导出。控制台原有分页仍为 8 批、254 个唯一根记录,源表保持 256 条。
有界磁盘采样观察到表/索引统计 1,835,008 → 16,564,224 字节,增量 14,729,216;预览仍为 100 条样本且有后续候选。该统计只证明本次样本增长,不用于推算生产容量。
最终门禁:`make test`(四个模块 race)、`make vet`、`make build`、`make docs-check`、`make console-e2e` 和修正断言后的完整 `make integration` 均通过。全量 MySQL 存储组用时约 286 秒,真实 SDK Worker/Event 进程故障组约 130 秒,最后的双节点流程暂停恢复组约 13 秒;包含独立 Inbox/Outbox 业务示例、Schema 30 及零外键验证。首轮集成中停用命名空间的预期错误码写成了 `FORBIDDEN`,实际服务端正确返回 `PERMISSION_DENIED`;修正测试后重跑全链路通过,没有放宽权限判断。

View File

@@ -0,0 +1,49 @@
# P1 稳定性与历史归档定向回归
这份报告定义当前代码的可复现入口,命令成功才算目标环境完成验证。它不把本机测试结果当作生产容量承诺。
```sh
ORBIT_TEST_MYSQL_DSN='root:...@tcp(127.0.0.1:3306)/orbit_test' \
ORBIT_TEST_SERVER_BINARY="$PWD/bin/orbit" \
ORBIT_TEST_WORKER_URL='http://127.0.0.1:8080' \
ORBIT_TEST_WORKER_CREDENTIALS_FILE="$PWD/.local/worker.credentials.json" \
make stability-check
```
入口覆盖:
- `TestIntegrationEventProcessCrossNodeReconnectStorm`:在两个独立 Orbit 进程之间反复切换 WebSocket 上游,验证重连风暴期间一次处理、一次确认和一次持久交付尝试。
- `TestIntegrationEventProcessSlowConsumerPeerProgress`:节点 A 的容量 1 处理器阻塞时,节点 B 仍可推进自己的交付,确认慢消费者隔离。
- `TestIntegrationWithTxRetriesPreCommitDeadlock`:两个真实 MySQL 事务反向加锁,使用数据库锁竞争和事务重试指标确认提交前死锁被有界恢复。
- `TestIntegrationFairDispatchApplicationIsolation` 与 `TestIntegrationWorkflowVersionAndRaces`:真实锁等待和多节点竞争路径,不使用固定 sleep 推断锁结果。
- `TestIntegrationRetentionDiskGrowthAndPreview`:写入带终态时间的有界任务样本,读取 `information_schema.TABLES` 的数据/索引字节增长,并核对保留策略预览只返回安全候选。
保留策略由数据库按命名空间保存,默认关闭归档。控制台单管理员通过“保留策略”页面修改窗口,要求版本、幂等键和原因;审计记录保留前后快照。导出是固定表目录的只读 JSON 快照,带 SHA-256 和策略版本,源数据仍保留。未确认交付、未知效果、未结束任务/流程、未处理 Inbox 和有效幂等记录不会成为普通归档候选。最终删除执行器、归档介质和目标拓扑 RPO/RTO 需要单独验收。
## 本地实测记录(2026-09-23)
环境为 macOS / arm64、Go 1.27.1、Docker MySQL 8.4.10。以下是隔离测试结果,不是百万企业容量或跨故障域高可用承诺。
上一批实现 `2f95d9c` 完成 `make test`、`make vet`、`make build`、`make docs-check` 与完整 `make integration`,包括独立 SDK 示例、真实进程故障及升级检查:
| 场景 | 观察结果 |
| --- | --- |
| 跨节点重连 | 两个独立进程间发生 4 次重连,业务处理 1 次,持久交付尝试 1 次 |
| 慢消费者 | 节点 A 消费阻塞期间,节点 B 仍完成独立交付 |
| 提交前死锁 | 真实反向加锁产生死锁,观察到 1 次有界重试后恢复 |
| 锁等待隔离 | 12 个阻塞连接参与应用公平派发验证,其他应用继续推进 |
| 新装与历史升级 | Schema 30;外键数量 0;v7 升级保留原 323 个索引组成项 |
| 有界磁盘增长 | 256 × 32 KiB 样本,表和索引统计从 1,818,624 增至 17,661,952 字节;候选预览 100 条、`more=true` |
本次继续补齐控制台归档分页和失败边界,在一次性数据库上运行 `go test -race -count=1 -run TestIntegrationRetention -v ./internal/storage/mysql`:
- `TestIntegrationRetentionPolicyIsSharedAuditedAndIdempotent`:不同 store 读取相同策略;相同幂等操作重放不增加修订号或重复写审计;旧版本修改被拒绝。
- `TestIntegrationRetentionDiskGrowthAndPreview`:256 条样本中 254 条终态,另 2 条保留为未知效果/运行中租约;终态记录采用相同完成时间验证复合游标。第一批 1 条,后续每批最多 37 条,共 8 批得到 254 条唯一根记录,源表仍为 256 条。
- 每批校验 SHA-256、策略版本及相同截止时间;跨类别/命名空间游标、无效游标、越界批量、业务 Bearer、旧策略和关闭策略均被拒绝。
- 此次物理统计增量为 12,238,848 字节(28,459,008 → 40,697,856)。该一次性数据库包含前面修正测试夹具时留下的样本,不能与上一轮绝对值直接比较;这里只验证有界写入增长及预览/导出的行为,不推导长期容量。
`make console-e2e` 在另一个空库从迁移与管理员初始化开始,验证:6 个已成功任务可见、WS Worker 排空、失败任务第 2 次成功、定时任务来源固定;保留策略保存/原键重放、旧版本 409、7 类预览、空快照摘要、退出后任务及策略接口 401 均通过。
本次 `make test`、`make vet`、`make build`、`make docs-check` 通过。服务端归档实现和迁移本次未修改,新增用例使用真实 MySQL 补充已有完整集成结果。
浏览器使用同一批隔离样本验证真实页面:关闭导出时仍可预览;存在未保存修改时禁用预览和导出;开启并保存后版本从 3 升至 4;首批 20 条、第二批累计 40 条且均通过浏览器 SHA-256 校验;切换类别重置进度并清除旧下载链接;空流程快照显示 0 条/已到末批且禁用继续导出。页面错误日志为空。开关点击区域和焦点提示已修正,截止时间带年份和 UTC,源数据不会被页面操作删除。

View File

@@ -0,0 +1,35 @@
# 首次部署与控制台任务闭环
这份回归验证 Orbit 从空数据库开始的第一条可运行链路:迁移、初始化管理员、启动服务、控制台登录、任务提交、Worker WebSocket 执行、结果回传、控制台查询、明确失败重试和定时计划。
最近一次验证:2026-09-22,本机 Docker Desktop、MySQL 8.4、Go 1.27.1;结果为 `console-e2e=passed`。Worker 成功确认 3 个任务,重试场景完成 2 次尝试,定时计划完成 2 次触发,控制台读取到 6 个成功任务,登出后业务 API 返回 401。
## 一键执行
在仓库根目录运行:
```sh
make console-e2e
```
脚本会创建一次性 Docker 网络、MySQL 容器和 Orbit 容器。所有应用、队列、凭据和管理员账号只写入临时数据库;脚本退出后删除容器、网络和临时文件,不连接已有本地数据库。
## 验收内容
| 阶段 | 验证内容 |
| --- | --- |
| 空库初始化 | 使用 `orbit-migrate up` 创建当前迁移链,数据库从零开始 |
| 管理员初始化 | `orbit-admin bootstrap` 同事务创建业务资源、Worker/计划权限和首个控制台管理员 |
| 服务启动 | Docker 启动 Orbit,`/health/ready` 返回就绪 |
| 控制台登录 | HttpOnly 会话登录,受保护页面和默认队列配置可读取 |
| WS 执行 | Go SDK 自动 Worker 通过 `pulse.worker.v1` 接收 3 个任务并确认结果 |
| 可靠重试 | 第一次明确失败且可重试,第二次执行成功,历史包含 2 次尝试 |
| 定时任务 | 计划生成固定版本任务,Worker 执行成功且来源为 `SCHEDULE` |
| 页面数据 | 控制台权限范围内能读取成功任务 |
| 退出保护 | 登出后业务 API 返回 401,会话不能继续调用任务接口 |
通过时输出 `console-e2e=passed`。脚本中的密码只保存在临时文件并通过标准输入交给初始化命令,不打印到日志。
## 生产边界
这是一条功能和发布前置回归,不是生产容量结论。生产环境还必须按[交付核对清单](../release-checklist.md)验证 TLS 终止、备份恢复、复制切换、容量、RPO/RTO 和告警通知;Worker 仍然由业务方独立部署。

View File

@@ -0,0 +1,57 @@
# Compose 部署基线验证
验证日期:2026-09-22
验证范围:MySQL 健康检查、迁移、运行账号权限收紧、schema 核验、Orbit 启动、就绪探针和核验失败门禁。
验证环境:Docker Desktop、MySQL 8.4、当前 `orbit:dev` 镜像。
## 已通过
### 静态配置门禁
使用临时数据库用户名、密码和镜像标签执行:
```sh
docker compose -f deploy/docker-compose.yml config
```
结果:Compose 配置成功展开,五个服务的依赖条件、健康检查、数据卷、端口和运行时 DSN 均可解析;密码没有写入仓库。
同时通过:
```sh
git diff --check
make docs-check
```
### 正常启动链路
在一次性 MySQL 8.4 和临时数据库卷中执行 Compose 服务链。当前开发机已有大量 Docker 自定义网络,地址池不足,因此本次测试只在临时覆盖文件中把容器放到 Docker 默认 `bridge` 网络并添加链接;生产 Compose 文件仍使用标准项目隔离网络,服务依赖语义没有改变。
验证顺序和结果:
1. `mysql` 通过 `mysqladmin ping` 健康检查。
2. `migrate` 使用 root 迁移连接完成当前迁移。
3. `grant` 撤销运行账号的额外数据库权限,仅保留运行所需的 DML 权限。
4. `verify` 使用运行账号完成 `orbit-admin verify`。
5. `orbit` 在前述一次性服务成功后启动。
6. `GET /health/ready` 返回成功。
最后输出:
```text
compose-e2e=passed readiness=ok grant=ok
```
### 失败门禁
第二次使用临时 MySQL,故意让 `verify` 使用错误的运行账号密码。迁移成功后,`verify` 以退出码 1 结束,Compose 报告:
```text
service "verify" didn't complete successfully: exit 1
```
Orbit 没有进入运行状态,证明 schema 核验失败不会把未核验节点拉入流量。
## 尚未由本报告关闭的事项
本报告只证明单节点 Compose 的启动顺序和失败边界,不代表生产高可用。数据库自动切换、跨故障域多节点、正式 Secret 注入、反向代理 TLS、备份恢复、容量余量和 RPO/RTO 仍按[生产验收总清单](../05-production-acceptance.md)逐项留证。首次管理员和控制台任务闭环的真实证据见[phase-8 控制台闭环](phase-8-console-e2e.md)。

View File

@@ -88,7 +88,7 @@ fmt.Println(current.ID, current.State, current.Revision)
受理回执不是业务执行结果。同键重报返回原始快照;当前状态应使用 GetTask。业务侧创建操作与平台提交之间存在双写边界,接入方应在自身事务中保存操作记录/Outbox,再使用稳定身份提交,不能依靠随机新键补发来“修复”未知结果。
[业务事务Outbox完整场景](../examples/task-outbox.md)已提供独立业务数据库、原子业务写入、持久化发送器、原身份恢复及CLI;核心SDK保持无MySQL/ORM依赖。业务适配包的Enqueue/SendOnce/Status与实际运行命令均在该手册说明。
[业务事务Outbox完整场景](../examples/task-outbox.md)已提供独立业务数据库、原子业务写入、持久化发送器、原身份恢复及CLI;核心SDK保持无MySQL/ORM依赖。现已抽为[可直接导入的 MySQL 适配包](outbox-mysql.md),支持 EnqueueTx/SendOnce/Run/Status 和受理确认钩子;[完整 HTTP 服务示例](../examples/http-service.md)演示宿主生命周期与结果入库。
## 3. 取消与错误处理

123
docs/sdk/outbox-mysql.md Normal file
View File

@@ -0,0 +1,123 @@
# MySQL 事务 Outbox 适配包
## 1. 用途与安装
业务写库与提交 Orbit 是两次独立操作。Outbox 将业务变更和待发请求放在同一个业务数据库事务中,再通过后台发送器提交任务。提交响应丢失时复用原操作身份,避免业务记录已保存而任务丢失。
```sh
go get git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql@codex/orbit-foundation
```
Go 会将分支解析为固定版本写入 go.mod。上线应固定已验证的版本,不在每次部署时追踪分支。本包要求 Go 1.27.1,使用 SDK v0.1.0 和 MySQL 8.4 验证;不向核心 SDK 加入 SQL/ORM 依赖,也不依赖 Orbit 服务端模型。其他数据库和 ORM 专用适配尚未提供。
```go
import (
"context"
"database/sql"
"time"
outbox "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
```
完整可编译工程在 [HTTP 服务示例](../../examples/http-service/README.md);[旧 CLI 示例](../examples/task-outbox.md)现在也调用本包,不维护第二份发送实现。
## 2. 数据库与事务约束
在**业务数据库**应用 [schema.sql](../../adapters/outbox/mysql/schema.sql)。表名保留 `sender_outbox`,兼容原示例已经初始化的表。中文模型见 [Record](../../adapters/outbox/mysql/model.go)。没有外键,不执行数据删除,不属于 Orbit 平台迁移。
- `New` 不建表、不连接网络、不接管连接池关闭;显式 `Init` 只创建新表,遇到已有表报错。
- 生产由业务迁移工具管理 `SchemaSQL()` 的内容;不要在每次启动时执行 Init。
- `db` 和传入 `tx` 必须来自同一个业务库、同一个主写端;不支持跨库事务或读副本确认。
- 自行创建连接池时配置 `parseTime=true`、UTC 会话时区和合理连接/读写超时;`Open(ctx, dsn)` 是可选的已配置连接池入口。
- 事务锁序统一为 **Outbox → 业务行**。先调用 EnqueueTx,再写业务行;受理回调遵守同样顺序。不要持有业务行锁反向等待 Outbox。
- 业务操作 ID 必须由调用方在首次请求前生成并保存,格式为 32 位小写十六进制;同命名空间唯一。HTTP 调用方丢响应后使用原 ID 与原内容重试。
## 3. 公共方法
| 方法 | 职责 |
| --- | --- |
| `Open(ctx, dsn) (*sql.DB, error)` | 可选的业务库连接池构造;不会自动建表 |
| `SchemaSQL() string` | 返回初始表结构供业务迁移管理 |
| `Init(ctx, db) error` | 显式创建新表,仅用于首次初始化 |
| `New(db, namespaceID, queueID, Options) (*Store, error)` | 固定目标及可选受理回调,可并发复用 |
| `EnqueueTx(ctx, tx, operationID, SubmitTaskRequest) (duplicate, error)` | 在调用方事务写入待发任务;不 Commit/Rollback、不发 HTTP |
| `SendOnce(ctx, client) (Record, error)` | 有界领取并处理一条到期记录;无到期项返回 ErrIdle |
| `Run(ctx, client, RunOptions) error` | 持续发送;取消后停止领取,限时收尾当前发送 |
| `Status(ctx, operationID) (Record, error)` | 只读固定命名空间及队列中的本地提交状态 |
基本构造不要求业务回调:
```go
sender, err := outbox.New(db, namespaceID, queueID, outbox.Options{})
if err != nil { return err }
```
`Options.OnAccepted` 类型为:
```go
type AcceptedHook func(context.Context, *sql.Tx, Record, orbit.SubmitTaskResult) error
```
回调与 Outbox 的 ACCEPTED 更新在同一个短事务内执行,并核对最新领取代次。可在此将平台 TaskID 关联到业务记录。回调必须幂等、响应 context,只做同库 SQL,不能 Commit/Rollback 或调用外部服务;失败整笔确认回滚,返回 `ErrAcceptance`。回调收到的 Record 仍是被锁定的 SENDING 记录,受理 TaskID 从 receipt 读取。不开启回调时,任务关联保存在 Outbox 自身,业务可按 OperationID 查询。
## 4. 加入现有业务事务
下面是接入函数片段。完整业务重复核对与错误处理见 [jobs/store.go](../../examples/http-service/internal/jobs/store.go)。
```go
func save(ctx context.Context, db *sql.DB, sender *outbox.Store,
operationID string, request orbit.SubmitTaskRequest) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil { return err }
defer tx.Rollback()
duplicate, err := sender.EnqueueTx(ctx, tx, operationID, request)
if err != nil { return err } // 必须回滚整笔事务
if !duplicate {
// 在这里使用 tx 写入自己的业务表。
// 每一个 SQL 错误都必须返回;禁止忽略错误后提交。
} else {
// 核对原业务记录与本次输入一致,不能再次执行扣款、计数等操作。
}
if err := tx.Commit(); err != nil {
return outbox.ErrCommitUnconfirmed
}
return nil
}
```
EnqueueTx 返回 nil 只代表该事务内写入/核对完成。事务提交前另一发送器看不到该记录;调用方回滚会同时撤销待发任务与业务写入。同一事务重复入队返回 duplicate=true,但仍需由调用方确认 Commit。默认 REPEATABLE READ 下的重复核对使用共享锁当前读,不依赖旧快照,也不升级重复 INSERT 持有的共享锁。
COMMIT 错误不能解释成“肯定没保存”。保留原 ID 与内容,重做同一业务事务核对;不得生成新 ID 补发。冻结请求按字节摘要核对,目标、提交键、默认值和数字精度保持不变。
## 5. 发送器与生命周期
```go
err := sender.Run(workerContext, client, outbox.RunOptions{
PollInterval: 500 * time.Millisecond,
ShutdownTimeout: 20 * time.Second,
})
```
RunOptions 默认轮询间隔 500ms(10ms–10s),关闭期限默认 20s(1ms–5min)。HTTP 在数据库事务外,单次网络阶段最多 15s、发送请求最多 10s;领取租约 30s,SQL 确认最多 5s。数据库时间控制首次入库后 24h、最多 100 次领取的总预算;进程重启不重置预算。
Run context 取消后停止新领取,在关闭期限内等待当前交换与本地确认,宿主必须等待 Run 返回。`ErrShutdownTimeout` 表示有未核对的持久记录,应在重启后沿原身份恢复。HTTP 暂时失败在库内退避;数据库错误或受理回调失败会让 Run 返回错误,由宿主降低就绪状态、关闭或按自身策略恢复。REVIEW 记录停止自动发送,需要监控 Status/数据库并人工核查。
允许多个 Run/进程共用同一范围,由 MySQL SKIP LOCKED 和递增代次协调。网络请求可能重复,平台使用原幂等键去重;不承诺业务外部效果恰好一次。
## 6. 状态与错误
| 状态/错误 | 含义及处理 |
| --- | --- |
| PENDING / SENDING | 未完成本地受理确认;不能据此认定 Orbit 没收到 |
| ACCEPTED | 已确认平台受理,TaskID/ReceiptJSON 可用;不是执行成功 |
| REVIEW | 权限、内容冲突、无效回执或预算耗尽;保留证据人工核对 |
| ErrConflict | 原操作的目标或内容不一致,不能覆盖 |
| ErrCommitUnconfirmed | 领取/确认 COMMIT 回执不明;保留原身份核对 |
| ErrLeaseLost | 领取代次已替换,旧发送器不得覆盖新结果;Run 可继续其他项 |
| ErrAcceptance | 本地受理关联回调失败,确认事务未获成功确认 |
| ErrStorage | 存储失败;当前进度以持久记录为准 |
| ErrIdle / ErrNotFound | 无到期记录 / 当前范围没有该操作 |
本版本只支持 SubmitTask,不提供流程启动/信号 Outbox 命令,不自动删除历史、重置发送预算或解除 REVIEW。任务完成另用可靠订阅与业务 Inbox,详见 [完整 HTTP 示例](../examples/http-service.md)。

View File

@@ -2,6 +2,8 @@
这份手册把 SDK、HTTP 查询和 WebSocket 结果通知串成一条可落地的接入链路。它只描述平台协议,不包含任何业务系统名称、行业规则或外部副作用。
完整可运行的业务宿主见[HTTP 服务示例](../examples/http-service.md),可复用事务能力见[MySQL Outbox 包](outbox-mysql.md)。
## 1. 接入边界
业务服务保存操作身份和请求正文,再调用 SDK 提交。`Idempotency-Key` 必须由业务库或 Outbox 持久化,网络超时、进程重启和响应不明时复用原键;不能生成新键猜测服务端是否已提交。受理回执只表示平台已经保存请求,不能当作任务或流程完成。
@@ -24,19 +26,19 @@ taskID := receipt.Task.ID
## 2. 完成通知与权威查询
结果通知使用 Event WebSocket。连接时声明 `orbit.events.v1` 子协议和允许的来源,SDK 自动发送心跳、在连接断开后重连,并把上一次未确认的投递证明带回新连接。处理器完成业务事务后再调用 `Ack`;处理器失败或进程在提交前退出时不要确认,平台会在租约到期后重投同一交付。
结果通知使用 Event WebSocket。连接时声明 `orbit.events.v1` 子协议和允许的来源,SDK 自动发送心跳、在连接断开后重连,并把上一次未确认的投递证明带回新连接。自动消费器的处理器完成业务事务后返回 nil,SDK 再发送 ACK;处理器失败或进程在提交前退出时不要确认,平台会在租约到期后重投同一交付。
```go
consumer := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{
consumer, err := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{
SubscriptionID: subscriptionID,
MaxHandlers: 8,
})
err := consumer.Run(ctx, func(ctx context.Context, delivery orbit.EventDelivery) error {
// 先在业务 Inbox 中按 delivery.EventID 去重,再提交业务事务。
// 返回 nil 后 SDK 才发送 ACK;返回错误会保留交付等待重投。
return applyOnce(ctx, delivery)
Capacity: 8,
Handle: func(ctx context.Context, event orbit.ResultEvent) error {
// 先按 event.ID 持久化去重,去重与业务更新在同一事务确认后返回 nil。
return applyOnce(ctx, event)
},
})
if err != nil { return err }
if err := consumer.Run(ctx); err != nil { return err }
```
通知只推动业务侧再次读取权威状态。收到 `task.completed`、`workflow.stopped` 或 `workflow.review_required` 后,调用 `GetTask`、`GetWorkflow` 或对应历史接口;事件快照不替代当前状态,也不表示外部效果已经完成。没有 WebSocket 时可以使用有界 HTTP 查询作为兜底,但必须使用退避、截止时间和业务键,不得无限轮询。

View File

@@ -0,0 +1,11 @@
# 在现有 HTTP 服务中使用 Orbit
可运行的 Go 服务:HTTP 业务事务 → 公共 MySQL Outbox → Orbit → 内嵌 Worker → WebSocket 结果 → 业务 Inbox。
- [完整准备、运行、API、故障与退出说明](../../docs/examples/http-service.md)
- [业务路由与组件装配](cmd/server/main.go)
- [生命周期顺序](cmd/server/lifecycle.go)
- [业务事务](internal/jobs/store.go)
- [业务 Inbox 示例实现](internal/inbox/store.go),沿用独立 result-inbox 示例的事件去重与版本约束
`init` 创建业务库表;`provision` 用稳定身份创建结果订阅;`serve` 使用配置中的订阅 ID,启动时无需 Orbit 在线。默认只监听回环地址 127.0.0.1:8090。接入已有服务时挂载业务路由,并接入原有鉴权、监控及生命周期。

View File

@@ -0,0 +1,251 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
outbox "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"github.com/go-sql-driver/mysql"
)
func TestIntegrationHTTPServiceRestartAndResults(t *testing.T) {
dsn, base, path := os.Getenv("ORBIT_TEST_HTTP_DSN"), os.Getenv("ORBIT_TEST_HTTP_URL"), os.Getenv("ORBIT_TEST_HTTP_CREDENTIALS_FILE")
if dsn == "" || base == "" || path == "" {
t.Skip("requires disposable business database and Orbit process")
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil || cfg.DBName != "orbit_http_test" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") || !strings.HasPrefix(base, "http://127.0.0.1:") {
t.Fatal("requires isolated loopback environment")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
db, err := outbox.Open(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
binary := filepath.Join(t.TempDir(), "server")
build := exec.CommandContext(ctx, filepath.Join(runtime.GOROOT(), "bin", "go"), "build", "-race", "-o", binary, ".")
if raw, err := build.CombinedOutput(); err != nil {
t.Fatalf("build failed %v %s", err, raw)
}
env := []string{}
for _, v := range os.Environ() {
if !strings.HasPrefix(v, "ORBIT_") && !strings.HasPrefix(v, "APP_") {
env = append(env, v)
}
}
env = append(env, "APP_MYSQL_DSN="+dsn, "APP_HTTP_ADDR=127.0.0.1:0", "ORBIT_URL="+base, "ORBIT_CREDENTIALS_FILE="+path)
call := func(args ...string) string {
t.Helper()
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Env = env
raw, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("command failed: %v %s", err, raw)
}
return strings.TrimSpace(string(raw))
}
call("init")
sub := call("provision")
if call("provision") != sub {
t.Fatal("provision changed subscription")
}
env = append(env, "ORBIT_SUBSCRIPTION_ID="+sub)
target, _ := url.Parse(base)
proxy := httputil.NewSingleHostReverseProxy(target)
var offline atomic.Bool
offline.Store(true)
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if offline.Load() {
http.Error(w, "offline", 503)
return
}
proxy.ServeHTTP(w, r)
}))
defer gateway.Close()
for i, v := range env {
if strings.HasPrefix(v, "ORBIT_URL=") {
env[i] = "ORBIT_URL=" + gateway.URL
}
}
type running struct {
cmd *exec.Cmd
done chan error
url string
stderr *bytes.Buffer
}
start := func() *running {
t.Helper()
cmd := exec.CommandContext(ctx, binary, "serve")
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr := new(bytes.Buffer)
cmd.Stderr = stderr
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
address := make(chan string, 1)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
if value, ok := strings.CutPrefix(scanner.Text(), "http_listening="); ok {
address <- "http://" + value
}
}
close(address)
}()
done := make(chan error, 1)
go func() { done <- cmd.Wait(); close(done) }()
run := &running{cmd: cmd, done: done, stderr: stderr}
t.Cleanup(func() { cmd.Process.Kill(); <-done })
select {
case run.url = <-address:
if run.url == "" {
<-done
t.Fatalf("startup failed: %s", stderr.String())
}
case <-ctx.Done():
t.Fatal("startup timeout")
}
return run
}
httpClient := &http.Client{Timeout: 5 * time.Second}
get := func(base, path string) map[string]any {
t.Helper()
r, err := httpClient.Get(base + path)
if err != nil {
t.Fatal(err)
}
defer r.Body.Close()
if r.StatusCode != 200 {
t.Fatalf("GET %s: %d", path, r.StatusCode)
}
var v map[string]any
if json.NewDecoder(r.Body).Decode(&v) != nil {
t.Fatal("invalid JSON")
}
return v
}
id, err := orbit.NewEventMessageID()
if err != nil {
t.Fatal(err)
}
payload := []byte(`{"operation_id":"` + id + `","source_ref":"objects/http-demo.txt"}`)
post := func(base string, body []byte, status int) map[string]any {
t.Helper()
r, err := httpClient.Post(base+"/jobs", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer r.Body.Close()
if r.StatusCode != status {
raw, _ := io.ReadAll(r.Body)
t.Fatalf("POST: %d %s", r.StatusCode, raw)
}
var out map[string]any
if status == 202 {
json.NewDecoder(r.Body).Decode(&out)
}
return out
}
wait := func(check func() bool) {
t.Helper()
for !check() {
select {
case <-ctx.Done():
t.Fatal("condition timeout")
case <-time.After(50 * time.Millisecond):
}
}
}
first := start()
wait(func() bool {
r, err := httpClient.Get(first.url + "/health/ready")
if err != nil {
return false
}
r.Body.Close()
return r.StatusCode == 200
})
if v := post(first.url, payload, 202); v["local_committed"] != true || v["duplicate"] != false {
t.Fatal(v)
}
wait(func() bool {
var n int
err := db.QueryRowContext(ctx, `SELECT send_count FROM sender_outbox WHERE operation_id=?`, id).Scan(&n)
return err == nil && n > 0
})
first.cmd.Process.Kill()
if err := <-first.done; err == nil {
t.Fatal("expected real SIGKILL")
}
offline.Store(false)
second := start()
wait(func() bool { return get(second.url, "/jobs/"+id)["execution"] == "SUCCEEDED" })
if post(second.url, payload, 202)["duplicate"] != true {
t.Fatal("original operation duplicated after restart")
}
post(second.url, bytes.ReplaceAll(payload, []byte("http-demo"), []byte("changed")), 409)
view := get(second.url, "/jobs/"+id)
result, ok := view["result"].(map[string]any)
if !ok || result["normalized_ref"] != "OBJECTS/HTTP-DEMO.TXT" {
t.Fatal(view)
}
var jobs, inboxRows, fks int
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sender_jobs WHERE operation_id=?`, id).Scan(&jobs); err != nil {
t.Fatal(err)
}
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM receiver_inbox WHERE queue_id=(SELECT queue_id FROM sender_outbox WHERE operation_id=?)`, id).Scan(&inboxRows); err != nil {
t.Fatal(err)
}
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fks); err != nil || jobs != 1 || inboxRows != 1 || fks != 0 {
t.Fatal(jobs, inboxRows, fks, err)
}
// 先看到平台持久ACK,再发SIGTERM,验证通知不是只停留在业务内存或数据库。
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var creds config
if json.Unmarshal(raw, &creds) != nil {
t.Fatal("credentials")
}
api, err := orbit.NewClient(base, orbit.Options{Token: creds.Token})
if err != nil {
t.Fatal(err)
}
wait(func() bool { v, err := api.GetSubscription(ctx, sub); return err == nil && v.PendingCount == 0 })
if err = second.cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
select {
case err = <-second.done:
if err != nil {
t.Fatalf("SIGTERM failed %v %s", err, second.stderr.String())
}
case <-ctx.Done():
t.Fatal("shutdown timeout")
}
t.Log("Orbit unavailable: HTTP transaction committed; real SIGKILL/restart recovered original operation; Worker + persistent Inbox + ACK completed once; conflict rejected; SIGTERM exited cleanly; zero foreign keys")
}

View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync/atomic"
"time"
)
// component 不接管进程信号;宿主负责为每一类生命周期提供独立取消和等待。
type component struct {
name string
run func(context.Context) error
}
type completion struct {
name string
err error
}
// serve 在HTTP监听已成功后启动后台组件;返回前完成HTTP→发送/执行→结果消费的关闭顺序。
func serve(parent context.Context, listener net.Listener, server *http.Server, ready *atomic.Bool, workers []component, consumer component, grace time.Duration) error {
workCtx, stopWork := context.WithCancel(context.Background())
defer stopWork()
eventCtx, stopEvents := context.WithCancel(context.Background())
defer stopEvents()
workDone := make(chan completion, len(workers))
eventDone := make(chan completion, 1)
for _, c := range workers {
go func() { workDone <- completion{c.name, c.run(workCtx)} }()
}
go func() { eventDone <- completion{consumer.name, consumer.run(eventCtx)} }()
httpDone := make(chan error, 1)
ready.Store(true)
go func() { httpDone <- server.Serve(listener) }()
remaining := len(workers)
eventsPending := true
var result error
add := func(c completion) {
if c.err != nil {
result = errors.Join(result, fmt.Errorf("%s: %w", c.name, c.err))
}
}
select {
case <-parent.Done():
case err := <-httpDone:
if !errors.Is(err, http.ErrServerClosed) {
result = errors.Join(result, err)
}
case c := <-workDone:
remaining--
if c.err == nil {
c.err = errors.New("unexpected component exit")
}
add(c)
case c := <-eventDone:
eventsPending = false
if c.err == nil {
c.err = errors.New("unexpected component exit")
}
add(c)
}
ready.Store(false)
// 用新的关闭上下文;进程信号的context此时可能已取消,不能用它调用Shutdown。
shut, cancel := context.WithTimeout(context.Background(), grace)
if err := server.Shutdown(shut); err != nil {
result = errors.Join(result, fmt.Errorf("http shutdown: %w", err))
_ = server.Close()
}
cancel()
stopWork()
deadline := time.NewTimer(grace)
defer deadline.Stop()
for remaining > 0 {
select {
case c := <-workDone:
remaining--
add(c)
case <-deadline.C:
result = errors.Join(result, errors.New("background shutdown deadline exceeded"))
remaining = 0
}
}
// 结果消费器最后停止。仍未投递的结果会在重启后由持久订阅恢复,不以本地空闲判断全局完成。
stopEvents()
if eventsPending {
timer := time.NewTimer(grace)
defer timer.Stop()
select {
case c := <-eventDone:
add(c)
case <-timer.C:
result = errors.Join(result, errors.New("consumer shutdown deadline exceeded"))
}
}
return result
}

View File

@@ -0,0 +1,90 @@
package main
import (
"context"
"errors"
"net"
"net/http"
"sync/atomic"
"testing"
"time"
)
func TestShutdownKeepsDependenciesUntilHTTPReturns(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
workerStopped := make(chan struct{})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-release; w.WriteHeader(200) })}
ready := new(atomic.Bool)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- serve(ctx, listener, server, ready, []component{{"worker", func(ctx context.Context) error { <-ctx.Done(); close(workerStopped); return nil }}}, component{"events", func(ctx context.Context) error {
<-ctx.Done()
select {
case <-workerStopped:
return nil
default:
return errors.New("consumer stopped before worker")
}
}}, time.Second)
}()
response := make(chan error, 1)
go func() {
r, e := http.Get("http://" + listener.Addr().String())
if e == nil {
r.Body.Close()
}
response <- e
}()
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("HTTP not entered")
}
cancel()
select {
case <-workerStopped:
t.Fatal("worker stopped during HTTP transaction")
case <-time.After(20 * time.Millisecond):
}
if ready.Load() {
t.Fatal("readiness remained true during shutdown")
}
close(release)
if err = <-response; err != nil {
t.Fatal(err)
}
select {
case err = <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown blocked")
}
}
func TestBackgroundFailureStopsServiceAndPeers(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
boom := errors.New("fatal worker")
stopped := make(chan struct{})
result := serve(context.Background(), listener, &http.Server{}, new(atomic.Bool), []component{{"worker", func(context.Context) error { return boom }}}, component{"consumer", func(ctx context.Context) error { <-ctx.Done(); close(stopped); return nil }}, time.Second)
if !errors.Is(result, boom) {
t.Fatal(result)
}
select {
case <-stopped:
default:
t.Fatal("consumer leaked")
}
}

View File

@@ -0,0 +1,234 @@
// server 演示将 Orbit SDK 接入现有 HTTP 服务;平台仍独立部署。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
outbox "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
"git.yuntuo.hk/k2safe/orbit/examples/http-service/internal/inbox"
"git.yuntuo.hk/k2safe/orbit/examples/http-service/internal/jobs"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
type config struct {
Token string `json:"token"`
Namespace string `json:"namespace_id"`
Queue string `json:"queue_id"`
}
func client() (*orbit.Client, config, error) {
c := config{Token: os.Getenv("ORBIT_TOKEN"), Namespace: os.Getenv("ORBIT_NAMESPACE_ID"), Queue: os.Getenv("ORBIT_QUEUE_ID")}
if file := os.Getenv("ORBIT_CREDENTIALS_FILE"); file != "" {
raw, err := os.ReadFile(file)
if err != nil {
return nil, c, errors.New("cannot read credential file")
}
var saved config
if json.Unmarshal(raw, &saved) != nil {
return nil, c, errors.New("invalid credential file")
}
if c.Token == "" {
c.Token = saved.Token
}
if c.Namespace == "" {
c.Namespace = saved.Namespace
}
if c.Queue == "" {
c.Queue = saved.Queue
}
}
if c.Token == "" || c.Namespace == "" || c.Queue == "" {
return nil, c, errors.New("Orbit token, namespace and queue are required")
}
api, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: c.Token})
return api, c, err
}
func run() error {
if len(os.Args) != 2 {
return errors.New("usage: server init|provision|serve")
}
command := os.Args[1]
if command != "init" && command != "provision" && command != "serve" {
return errors.New("unknown command")
}
process, signalStop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer signalStop()
startup, stop := context.WithTimeout(process, 10*time.Second)
defer stop()
if command == "provision" {
api, c, err := client()
if err != nil {
return err
}
// 首次使用专属队列前创建,键和内容固定;部署重试不可改名/换键。运行期间从配置复用ID。
result, err := api.CreateSubscription(startup, "http-service-sub-"+c.Queue, orbit.CreateSubscriptionRequest{Name: "http-service-" + c.Queue, QueueID: c.Queue, MaxPending: 100, MaxAttempts: 20})
if err != nil {
return err
}
fmt.Println(result.Subscription.ID)
return nil
}
db, err := outbox.Open(startup, os.Getenv("APP_MYSQL_DSN"))
if err != nil {
return err
}
// 所有组件完成关闭后才关闭共享连接池;初始化/构造/监听失败也走同一回收路径。
defer db.Close()
if command == "init" {
if err = jobs.Init(startup, db); err != nil {
return err
}
if err = inbox.Init(startup, db); err != nil {
return err
}
fmt.Println("business_schema_initialized=true")
return nil
}
api, c, err := client()
if err != nil {
return err
}
store, err := jobs.New(db, c.Namespace, c.Queue)
if err != nil {
return err
}
// 尽早核验业务迁移;不会自动建表,也不依赖Orbit在线才能启动。
for _, query := range []string{
`SELECT namespace_id,queue_id,operation_id,state FROM sender_outbox LIMIT 0`,
`SELECT namespace_id,operation_id,source_ref,task_id FROM sender_jobs LIMIT 0`,
`SELECT consumer_name,event_id,event_digest FROM receiver_inbox LIMIT 0`,
`SELECT consumer_name,queue_id,task_id,last_revision,result_json FROM receiver_task_views LIMIT 0`,
} {
if _, err = db.ExecContext(startup, query); err != nil {
return errors.New("business schema missing")
}
}
receiver, err := inbox.New(db, "http-service", c.Queue)
if err != nil {
return err
}
worker, err := orbit.NewWorker(api, orbit.WorkerOptions{QueueID: c.Queue, Capacity: 4, ShutdownTimeout: 20 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: "http.object.prepare", HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: prepare}}})
if err != nil {
return err
}
consumer, err := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{SubscriptionID: os.Getenv("ORBIT_SUBSCRIPTION_ID"), Capacity: 4, ShutdownTimeout: 10 * time.Second, Handle: receiver.Handle})
if err != nil {
return err
}
ready := new(atomic.Bool)
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("live\n")) })
mux.HandleFunc("GET /health/ready", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
if !ready.Load() || db.PingContext(ctx) != nil {
http.Error(w, "not ready", 503)
return
}
w.Write([]byte("ready\n"))
})
mux.HandleFunc("POST /jobs", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "stopping", 503)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
var in struct {
OperationID string `json:"operation_id"`
SourceRef string `json:"source_ref"`
}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if d.Decode(&in) != nil || d.Decode(new(any)) != io.EOF {
http.Error(w, "invalid request", 400)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
payload, _ := json.Marshal(map[string]string{"source_ref": in.SourceRef})
duplicate, err := store.Enqueue(ctx, in.OperationID, in.SourceRef, orbit.SubmitTaskRequest{QueueID: c.Queue, Handler: "http.object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, BusinessKey: in.OperationID, TaskTimeoutMS: 86400000, RecoverySafety: orbit.RecoveryIdempotent, Retry: orbit.RetryPolicy{MaxAttempts: 3, AttemptTimeoutMS: 10000}})
if err != nil {
httpFailure(w, err)
return
}
writeJSON(w, 202, map[string]any{"operation_id": in.OperationID, "local_committed": true, "duplicate": duplicate})
})
mux.HandleFunc("GET /jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := store.View(ctx, r.PathValue("id"))
if err != nil {
httpFailure(w, err)
return
}
writeJSON(w, 200, result)
})
addr := os.Getenv("APP_HTTP_ADDR")
if addr == "" {
addr = "127.0.0.1:8090"
}
host, _, err := net.SplitHostPort(addr)
if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
return errors.New("example requires a loopback APP_HTTP_ADDR; mount handlers behind your application authentication for production")
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return errors.New("cannot listen on APP_HTTP_ADDR")
}
defer listener.Close()
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
fmt.Printf("http_listening=%s\n", listener.Addr())
return serve(process, listener, server, ready, []component{{"outbox", func(ctx context.Context) error {
return store.Run(ctx, api, outbox.RunOptions{ShutdownTimeout: 20 * time.Second})
}}, {"worker", worker.Run}}, component{"events", consumer.Run}, 25*time.Second)
}
func httpFailure(w http.ResponseWriter, err error) {
code, text := 503, "unconfirmed; retry original operation and content"
if errors.Is(err, outbox.ErrInvalid) {
code, text = 400, "invalid request"
}
if errors.Is(err, outbox.ErrConflict) {
code, text = 409, "operation content conflict"
}
if errors.Is(err, outbox.ErrNotFound) {
code, text = 404, "operation not found"
}
http.Error(w, text, code)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func prepare(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
var in struct {
SourceRef string `json:"source_ref"`
}
if json.Unmarshal(e.Task.Payload, &in) != nil || in.SourceRef == "" {
return nil, &orbit.WorkerFailure{Code: "INVALID_SOURCE"}
}
if err := ctx.Err(); err != nil {
return nil, err
}
// 纯计算可重复;实际外部副作用必须由业务实现幂等或核对,不能照搬幂等声明。
return json.Marshal(map[string]string{"source_ref": in.SourceRef, "normalized_ref": strings.ToUpper(in.SourceRef)})
}

View File

@@ -0,0 +1,16 @@
module git.yuntuo.hk/k2safe/orbit/examples/http-service
go 1.27.1
require (
git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql v0.0.0
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0
github.com/go-sql-driver/mysql v1.10.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/coder/websocket v1.8.15 // indirect
)
replace git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql => ../../adapters/outbox/mysql

View File

@@ -0,0 +1,8 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0 h1:OJp+U8dDNKjiXy4ZhO4MMq68Ek8TkjphUAqsjOOg77c=
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0/go.mod h1:9KlMW7Gy+EGaDoEwr3wHHFY47mnAymSfYeQpMS7h4ag=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=

View File

@@ -0,0 +1,28 @@
package inbox
import "time"
// Record 对应receiver_inbox,与业务视图同事务提交;存在记录才表示该事件处理已提交。
type Record struct {
ConsumerName string // ConsumerName 固定用途名;进程重启及多实例必须复用。
QueueID string // QueueID 固定来源队列,不能从连接会话推导。
EventID string // EventID 稳定事件身份,不使用交付ID或代次。
EventDigest []byte // EventDigest 32字节SHA256,防止同事件身份被换成不同内容。
EventJSON []byte // EventJSON 规范化冻结事件,含业务数据、不含租约秘密。
Applied bool // Applied 该事件是否推进业务视图;旧版本为false但仍可靠保存。
ReceivedAt time.Time // ReceivedAt 数据库UTC受理时间,微秒精度。
}
// TaskView 对应receiver_task_views;这是接收方业务数据,不是Orbit任务权威状态。
type TaskView struct {
ConsumerName string // ConsumerName 与Inbox相同的固定用途。
QueueID string // QueueID 与Inbox相同的绑定队列。
TaskID string // TaskID 原任务标识,业务视图主键的一部分。
LastRevision uint64 // LastRevision 已应用的最大任务版本;0只允许在未提交事务的占位行中。
LastEventID *string // LastEventID 最后推进视图的事件;仅占位状态可为空。
LastState string // LastState 冻结结果状态;NEEDS_REVIEW不能作为成功或最终失败。
ResultJSON []byte // ResultJSON 当前结果,nil表示来源未提供;数值保持精确。
AppliedEvents uint64 // AppliedEvents 实际推进次数,重复及迟到旧版本不增加。
CreatedAt time.Time // CreatedAt 视图首次创建的UTC时间,微秒精度。
UpdatedAt time.Time // UpdatedAt 最后推进的UTC时间,微秒精度。
}

View File

@@ -0,0 +1,32 @@
-- 接收方独立数据库的初始示例表;不属于Orbit平台迁移,不创建外键或级联删除。
CREATE TABLE receiver_inbox (
consumer_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定消费者用途;同一用途重启或多实例必须复用',
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '接收方绑定的队列标识;隔离不同来源范围',
event_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '原事件标识;跨连接及交付代次保持不变',
event_digest BINARY(32) NOT NULL COMMENT '规范化冻结事件JSON的SHA256;同身份不同内容拒绝确认',
event_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '冻结事件及业务结果;包含业务数据须限制访问,不含租约证明',
applied BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否推进业务视图;迟到的旧版本只保留Inbox历史',
received_at DATETIME(6) NOT NULL COMMENT '本次业务事务受理事件的UTC时间,微秒精度',
PRIMARY KEY (consumer_name,queue_id,event_id),
CONSTRAINT ck_receiver_event_json CHECK (JSON_VALID(event_json)),
CONSTRAINT ck_receiver_applied CHECK (applied IN (0,1))
) ENGINE=InnoDB COMMENT='事务Inbox;去重记录与业务视图一起提交,禁止独立预写成功标志';
CREATE TABLE receiver_task_views (
consumer_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定消费者用途;与Inbox使用相同范围',
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定队列来源;由处理器事务前验证归属',
task_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '原任务标识;每个任务建立一份版本化业务视图',
last_revision BIGINT UNSIGNED NOT NULL COMMENT '已应用最大任务版本;0仅为同事务中尚未更新的占位状态',
last_event_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '推进当前视图的事件;占位行为空,提交后的有效视图非空',
last_state VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '冻结任务结果状态;UNINITIALIZED只用于同事务内占位',
result_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '当前结果JSON;来源未提供结果时为空,保留精确数字',
applied_events BIGINT UNSIGNED NOT NULL COMMENT '实际推进视图的事件次数;重复和旧版本不增加',
created_at DATETIME(6) NOT NULL COMMENT '视图首次建立的UTC时间,微秒精度',
updated_at DATETIME(6) NOT NULL COMMENT '视图最后推进的UTC时间,微秒精度',
PRIMARY KEY (consumer_name,queue_id,task_id),
CONSTRAINT ck_receiver_revision CHECK (
(last_revision=0 AND last_event_id IS NULL AND last_state='UNINITIALIZED' AND applied_events=0)
OR (last_revision>0 AND last_event_id IS NOT NULL AND applied_events>0 AND last_state IN ('SUCCEEDED','FAILED','CANCELLED','NEEDS_REVIEW'))
),
CONSTRAINT ck_receiver_result CHECK (result_json IS NULL OR JSON_VALID(result_json))
) ENGINE=InnoDB COMMENT='业务侧任务结果视图;锁定单任务行后仅接受更高版本,防止迟到结果倒退';

View File

@@ -0,0 +1,314 @@
// Package inbox 演示业务端同事务去重和版本化结果视图,不是通用业务恰好一次框架。
package inbox
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"io"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"github.com/go-sql-driver/mysql"
)
//go:embed schema.sql
var schema string
var (
ErrInvalidEvent = errors.New("receiver event or scope is invalid")
ErrEventConflict = errors.New("receiver event identity has conflicting content; investigate before acknowledgement")
ErrRevisionConflict = errors.New("receiver task revision has conflicting event identities")
ErrStorage = errors.New("receiver storage operation failed; event remains unacknowledged")
ErrCommitUnconfirmed = errors.New("receiver transaction commit is unconfirmed; reconcile original event on redelivery")
namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
)
type Store struct {
db *sql.DB
consumer, queue string
}
type Result struct {
Duplicate bool // Duplicate 当前事件已在相同消费者范围内提交,内容摘要也相同。
Applied bool // Applied 原事务是否推进视图;重复命中时返回历史值,不表示再次推进。
}
// Open 仅连接接收方数据库;密码和原始DSN不进入错误输出,不自动创建或修改表。
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil || cfg.DBName == "" {
return nil, errors.New("valid receiver MySQL DSN with explicit database is required")
}
cfg.ParseTime = true
cfg.Loc = time.UTC
cfg.MultiStatements = false
cfg.AllowAllFiles = false
cfg.Timeout = 5 * time.Second
cfg.ReadTimeout = 8 * time.Second
cfg.WriteTimeout = 8 * time.Second
if cfg.Params == nil {
cfg.Params = map[string]string{}
}
cfg.Params["time_zone"] = "'+00:00'"
connector, err := mysql.NewConnector(cfg)
if err != nil {
return nil, ErrStorage
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(5 * time.Minute)
if err = db.PingContext(ctx); err != nil {
db.Close()
return nil, ErrStorage
}
return db, nil
}
// Init 初始化专用空数据库。已有表会报错;升级已有业务数据库应使用接收方自己的迁移。
func Init(ctx context.Context, db *sql.DB) error {
for _, statement := range strings.Split(schema, ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err := db.ExecContext(ctx, statement); err != nil {
return ErrStorage
}
}
return nil
}
func New(db *sql.DB, consumer, queue string) (*Store, error) {
if db == nil || !namePattern.MatchString(consumer) || !idPattern.MatchString(queue) {
return nil, ErrInvalidEvent
}
return &Store{db: db, consumer: consumer, queue: queue}, nil
}
// Handle 可直接作为EventConsumer处理器;只有事务确认提交或命中相同已提交记录才返回nil。
func (s *Store) Handle(ctx context.Context, event orbit.ResultEvent) error {
_, err := s.Apply(ctx, event)
return err
}
// Apply 不含网络副作用。事务失败或COMMIT响应未知时不允许SDK发送ACK。
func (s *Store) Apply(parent context.Context, event orbit.ResultEvent) (Result, error) {
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
raw, digest, err := s.document(event)
if err != nil {
return Result{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return Result{}, ErrStorage
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO receiver_inbox(consumer_name,queue_id,event_id,event_digest,event_json,applied,received_at) VALUES(?,?,?,?,?,FALSE,UTC_TIMESTAMP(6))`, s.consumer, s.queue, event.ID, digest[:], raw)
if err != nil {
var duplicate *mysql.MySQLError
if !errors.As(err, &duplicate) || duplicate.Number != 1062 {
return Result{}, ErrStorage
}
// 唯一键冲突已等待胜出事务完成。结束本事务,再核对已提交记录;不能忽略其他SQL错误。
if err = tx.Rollback(); err != nil {
return Result{}, ErrStorage
}
var saved []byte
var applied bool
if err = s.db.QueryRowContext(ctx, `SELECT event_digest,applied FROM receiver_inbox WHERE consumer_name=? AND queue_id=? AND event_id=?`, s.consumer, s.queue, event.ID).Scan(&saved, &applied); err != nil {
return Result{}, ErrStorage
}
if !bytes.Equal(saved, digest[:]) {
return Result{}, ErrEventConflict
}
return Result{Duplicate: true, Applied: applied}, nil
}
// 原子建立或取得单任务行锁,不依赖客户端RowsAffected选项判断新旧。
_, err = tx.ExecContext(ctx, `INSERT INTO receiver_task_views(consumer_name,queue_id,task_id,last_revision,last_state,applied_events,created_at,updated_at) VALUES(?,?,?,0,'UNINITIALIZED',0,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE task_id=receiver_task_views.task_id`, s.consumer, s.queue, event.AggregateID)
if err != nil {
return Result{}, ErrStorage
}
var revision uint64
if err = tx.QueryRowContext(ctx, `SELECT last_revision FROM receiver_task_views WHERE consumer_name=? AND queue_id=? AND task_id=? FOR UPDATE`, s.consumer, s.queue, event.AggregateID).Scan(&revision); err != nil {
return Result{}, ErrStorage
}
if event.AggregateRevision == revision {
return Result{}, ErrRevisionConflict
}
applied := event.AggregateRevision > revision
if applied {
var result any
if len(event.Snapshot.Result) > 0 {
result = []byte(event.Snapshot.Result)
}
_, err = tx.ExecContext(ctx, `UPDATE receiver_task_views SET last_revision=?,last_event_id=?,last_state=?,result_json=?,applied_events=applied_events+1,updated_at=UTC_TIMESTAMP(6) WHERE consumer_name=? AND queue_id=? AND task_id=?`, event.AggregateRevision, event.ID, string(event.Snapshot.State), result, s.consumer, s.queue, event.AggregateID)
if err != nil {
return Result{}, ErrStorage
}
if _, err = tx.ExecContext(ctx, `UPDATE receiver_inbox SET applied=TRUE WHERE consumer_name=? AND queue_id=? AND event_id=?`, s.consumer, s.queue, event.ID); err != nil {
return Result{}, ErrStorage
}
}
if err = tx.Commit(); err != nil {
return Result{}, ErrCommitUnconfirmed
}
return Result{Applied: applied}, nil
}
func (s *Store) document(event orbit.ResultEvent) ([]byte, [32]byte, error) {
var none [32]byte
snapshot := event.Snapshot
if len(event.Details)+len(snapshot.Result) > 512<<10 {
return nil, none, ErrInvalidEvent
}
if !idPattern.MatchString(event.ID) || event.Sequence == 0 || event.AggregateKind != "task" || !idPattern.MatchString(event.AggregateID) || snapshot.TaskID != event.AggregateID || snapshot.QueueID != s.queue || event.AggregateRevision == 0 || event.AggregateRevision != snapshot.Revision || event.OccurredAt.IsZero() || snapshot.SchemaVersion != 1 || snapshot.CurrentAttemptID != nil && !idPattern.MatchString(*snapshot.CurrentAttemptID) {
return nil, none, ErrInvalidEvent
}
states := map[string]orbit.TaskState{"task.succeeded": orbit.TaskSucceeded, "task.failed": orbit.TaskFailed, "task.cancelled": orbit.TaskCancelled, "task.needs_review": orbit.TaskNeedsReview}
if state, ok := states[event.Type]; !ok || state != snapshot.State {
return nil, none, ErrInvalidEvent
}
if len(bytes.TrimSpace(event.Details)) == 0 || bytes.TrimSpace(event.Details)[0] != '{' {
return nil, none, ErrInvalidEvent
}
event.OccurredAt = event.OccurredAt.UTC()
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if encoder.Encode(event) != nil || buffer.Len() > 512<<10 {
return nil, none, ErrInvalidEvent
}
// 对象键与空白规范化,数字使用json.Number保留原精度及十进制表达。
canonical, err := canonicalJSON(buffer.Bytes())
if err != nil {
return nil, none, err
}
return canonical, sha256.Sum256(canonical), nil
}
func canonicalJSON(raw []byte) ([]byte, error) {
if !utf8.Valid(raw) || !json.Valid(raw) || !pairedUnicode(raw) {
return nil, ErrInvalidEvent
}
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
var value func(int) (any, error)
value = func(depth int) (any, error) {
if depth > 32 {
return nil, ErrInvalidEvent
}
token, err := d.Token()
if err != nil {
return nil, ErrInvalidEvent
}
switch token {
case json.Delim('{'):
out := map[string]any{}
for d.More() {
key, err := d.Token()
if err != nil {
return nil, ErrInvalidEvent
}
name, ok := key.(string)
if !ok {
return nil, ErrInvalidEvent
}
if _, found := out[name]; found {
return nil, ErrInvalidEvent
}
out[name], err = value(depth + 1)
if err != nil {
return nil, err
}
}
_, err = d.Token()
return out, err
case json.Delim('['):
out := []any{}
for d.More() {
item, err := value(depth + 1)
if err != nil {
return nil, err
}
out = append(out, item)
}
_, err = d.Token()
return out, err
default:
return token, nil
}
}
doc, err := value(0)
if err != nil {
return nil, ErrInvalidEvent
}
if _, err = d.Token(); err != io.EOF {
return nil, ErrInvalidEvent
}
var out bytes.Buffer
e := json.NewEncoder(&out)
e.SetEscapeHTML(false)
if e.Encode(doc) != nil {
return nil, ErrInvalidEvent
}
return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil
}
// 不将未配对UTF-16转义静默转换成替代字符,避免摘要与实际业务文本失真。
func pairedUnicode(raw []byte) bool {
inside := false
for i := 0; i < len(raw); i++ {
if raw[i] == '"' {
inside = !inside
continue
}
if !inside || raw[i] != '\\' {
continue
}
i++
if i >= len(raw) {
return false
}
if raw[i] != 'u' {
continue
}
if i+4 >= len(raw) {
return false
}
n, err := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
if err != nil {
return false
}
i += 4
if n < 0xd800 || n > 0xdfff {
continue
}
if n > 0xdbff || i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return false
}
low, err := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
if err != nil || low < 0xdc00 || low > 0xdfff {
return false
}
i += 6
}
return !inside
}
// View 返回业务侧当前视图;不会从Orbit重新拼装结果。
func (s *Store) View(ctx context.Context, task string) (TaskView, error) {
out := TaskView{ConsumerName: s.consumer, QueueID: s.queue, TaskID: task}
err := s.db.QueryRowContext(ctx, `SELECT last_revision,last_event_id,last_state,result_json,applied_events,created_at,updated_at FROM receiver_task_views WHERE consumer_name=? AND queue_id=? AND task_id=?`, s.consumer, s.queue, task).Scan(&out.LastRevision, &out.LastEventID, &out.LastState, &out.ResultJSON, &out.AppliedEvents, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return TaskView{}, ErrStorage
}
return out, nil
}

View File

@@ -0,0 +1,59 @@
package inbox
import (
"bytes"
"encoding/json"
"errors"
"testing"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func newID(t *testing.T) string {
t.Helper()
id, err := orbit.NewEventMessageID()
if err != nil {
t.Fatal(err)
}
return id
}
func testEvent(t *testing.T, queue string) orbit.ResultEvent {
t.Helper()
task := newID(t)
return orbit.ResultEvent{ID: newID(t), Sequence: 7, Type: "task.succeeded", AggregateKind: "task", AggregateID: task, AggregateRevision: 9007199254740993, OccurredAt: time.Now().UTC().Truncate(time.Microsecond), Details: json.RawMessage(`{"origin":"worker"}`), Snapshot: orbit.TaskResultSnapshot{SchemaVersion: 1, TaskID: task, QueueID: queue, State: orbit.TaskSucceeded, Revision: 9007199254740993, Result: json.RawMessage(`{"n":9007199254740993,"text":"<&>"}`)}}
}
func TestFrozenEventIdentity(t *testing.T) {
s := &Store{consumer: "result-view", queue: newID(t)}
e := testEvent(t, s.queue)
raw, a, err := s.document(e)
if err != nil || !bytes.Contains(raw, []byte("9007199254740993")) || !bytes.Contains(raw, []byte("<&>")) {
t.Fatal("precision changed", err)
}
e.Snapshot.Result = json.RawMessage(`{ "text": "<&>", "n": 9007199254740993 }`)
_, b, err := s.document(e)
if err != nil || a != b {
t.Fatal("equivalent formatting changed identity", err)
}
e.OccurredAt = e.OccurredAt.In(time.FixedZone("offset", 8*3600))
_, b, err = s.document(e)
if err != nil || a != b {
t.Fatal("timezone representation changed event identity", err)
}
e.Snapshot.Result = json.RawMessage(`{"n":9007199254740994,"text":"<&>"}`)
_, b, err = s.document(e)
if err != nil || a == b {
t.Fatal("changed result not detected", err)
}
e.Snapshot.QueueID = newID(t)
if _, _, err = s.document(e); !errors.Is(err, ErrInvalidEvent) {
t.Fatal("wrong receiver scope accepted", err)
}
}
func TestCanonicalRejectsDuplicateAndInvalidJSON(t *testing.T) {
for _, raw := range []string{`{"a":1,"a":2}`, `{"a":null,"a":2}`, `{"a":1} {}`, `{"a":`, `{"a":"\ud800"}`, `{"a":"\udfff"}`, "\xff"} {
if _, err := canonicalJSON([]byte(raw)); !errors.Is(err, ErrInvalidEvent) {
t.Fatal("invalid document accepted")
}
}
}

View File

@@ -0,0 +1,9 @@
CREATE TABLE sender_jobs (
namespace_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '与Outbox相同的命名空间范围',
operation_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '本地业务操作标识,与Outbox同事务创建',
source_ref VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL COMMENT '待处理对象引用,首次入库后固定,不存对象正文或凭据',
task_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '已确认的平台受理任务,为空不证明远端未受理',
created_at DATETIME(6) NOT NULL COMMENT '业务记录创建UTC时间,微秒精度',
updated_at DATETIME(6) NOT NULL COMMENT '关联任务最近变化UTC时间,微秒精度',
PRIMARY KEY (namespace_id,operation_id)
) ENGINE=InnoDB COMMENT='接入方示例业务记录,与待发请求及受理关联分别同事务保存';

View File

@@ -0,0 +1,126 @@
// Package outbox 演示在业务事务中使用公共 MySQL Outbox 适配包。
package jobs
import (
"context"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"strings"
"unicode"
"unicode/utf8"
adapter "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
//go:embed schema.sql
var schema string
var (
ErrInvalid = adapter.ErrInvalid
ErrStorage = adapter.ErrStorage
ErrConflict = adapter.ErrConflict
ErrCommitUnconfirmed = adapter.ErrCommitUnconfirmed
ErrIdle = adapter.ErrIdle
)
type Record = adapter.Record
// Store 仅补充示例业务事务;发送、查询、租约由公共包负责。
type Store struct {
*adapter.Store
db *sql.DB
namespace string
}
var Open = adapter.Open
func Init(ctx context.Context, db *sql.DB) error {
if err := adapter.Init(ctx, db); err != nil {
return err
}
if _, err := db.ExecContext(ctx, schema); err != nil {
return ErrStorage
}
return nil
}
func New(db *sql.DB, namespace, queue string) (*Store, error) {
s, err := adapter.New(db, namespace, queue, adapter.Options{OnAccepted: accepted})
if err != nil {
return nil, err
}
return &Store{s, db, namespace}, nil
}
func accepted(ctx context.Context, tx *sql.Tx, r adapter.Record, receipt orbit.SubmitTaskResult) error {
var existing *string
if err := tx.QueryRowContext(ctx, `SELECT task_id FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, r.NamespaceID, r.OperationID).Scan(&existing); err != nil {
return ErrStorage
}
if existing != nil && *existing != receipt.Task.ID {
return ErrConflict
}
_, err := tx.ExecContext(ctx, `UPDATE sender_jobs SET task_id=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, receipt.Task.ID, r.NamespaceID, r.OperationID)
if err != nil {
return ErrStorage
}
return nil
}
func (s *Store) Enqueue(ctx context.Context, id, source string, in orbit.SubmitTaskRequest) (bool, error) {
if !utf8.ValidString(source) || strings.TrimSpace(source) == "" || utf8.RuneCountInString(source) > 512 || strings.ContainsFunc(source, unicode.IsControl) {
return false, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return false, ErrStorage
}
defer tx.Rollback()
duplicate, err := s.Store.EnqueueTx(ctx, tx, id, in)
if err != nil {
return false, err
}
if duplicate {
var saved string
if err = tx.QueryRowContext(ctx, `SELECT source_ref FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, s.namespace, id).Scan(&saved); err != nil {
return false, ErrStorage
}
if saved != source {
return false, ErrConflict
}
} else {
if _, err = tx.ExecContext(ctx, `INSERT INTO sender_jobs(namespace_id,operation_id,source_ref,created_at,updated_at) VALUES(?,?,?,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, s.namespace, id, source); err != nil {
return false, ErrStorage
}
}
if tx.Commit() != nil {
return false, ErrCommitUnconfirmed
}
return duplicate, nil
}
// View 是业务HTTP的读取结果;Submission只表示提交阶段,Execution来自持久化Inbox。
type View struct {
OperationID string `json:"operation_id"` // OperationID 调用方提供的稳定操作标识。
Submission string `json:"submission"` // Submission 待发/发送中/已受理/需核查,不是执行结果。
Code string `json:"code"` // Code 最近稳定的提交分类。
TaskID *string `json:"task_id,omitempty"` // TaskID 已核对的平台任务,未确认时为空。
Execution string `json:"execution,omitempty"` // Execution 业务Inbox最后收到的任务状态。
Result json.RawMessage `json:"result,omitempty"` // Result 业务Inbox中的结果,尚未到达时为空。
}
func (s *Store) View(ctx context.Context, id string) (View, error) {
r, err := s.Status(ctx, id)
if err != nil {
return View{}, err
}
v := View{OperationID: id, Submission: r.State, Code: r.LastCode, TaskID: r.TaskID}
if r.TaskID != nil {
var result []byte
err = s.db.QueryRowContext(ctx, `SELECT last_state,result_json FROM receiver_task_views WHERE consumer_name='http-service' AND queue_id=? AND task_id=?`, r.QueueID, *r.TaskID).Scan(&v.Execution, &result)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return View{}, ErrStorage
}
v.Result = result
}
return v, nil
}

View File

@@ -4,7 +4,7 @@
- [完整初始化、运行、恢复与接口说明](../../docs/examples/task-outbox.md)
- [命令行程序](cmd/sender/main.go):init、enqueue、send、status、demo。
- [业务事务](internal/outbox/store.go)、[发送器](internal/outbox/sender.go)、[中文模型](internal/outbox/model.go)与[无外键表结构](internal/outbox/schema.sql)。
- [业务事务](internal/outbox/store.go)、[发送器](../../adapters/outbox/mysql/sender.go)、[中文模型](internal/outbox/model.go)与[无外键表结构](internal/outbox/schema.sql)。
本模块是可运行的业务接入参考。核心SDK不引入MySQL驱动;生产业务应将本地事务接入自身业务模型、权限、审计与迁移,固定SDK版本并验证兼容性。本例的internal包不作为已发布通用SDK包承诺兼容。
@@ -17,3 +17,5 @@ GOWORK=off go build ./cmd/sender
```
真实数据库、HTTP、COMMIT响应丢失、CLI及发送进程故障由仓库根目录的`make integration`统一执行,使用专门的一次性数据库。没有测试环境变量时Integration会跳过。
发送核心已抽为[公共 MySQL 适配包](../../adapters/outbox/mysql/README.md);本项目仅保留示例业务事务。已有 sender_outbox / sender_jobs 表保持兼容。

View File

@@ -3,13 +3,16 @@ module git.yuntuo.hk/k2safe/orbit/examples/task-outbox
go 1.27.1
require (
git.yuntuo.hk/k2safe/orbit/sdk/go v0.0.0
github.com/go-sql-driver/mysql v1.10.1
git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql v0.0.0
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/go-sql-driver/mysql v1.10.1 // indirect
)
replace git.yuntuo.hk/k2safe/orbit/sdk/go => ../../sdk/go
replace git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql => ../../adapters/outbox/mysql

View File

@@ -1,26 +1,13 @@
package outbox
import "time"
import (
"time"
// Record 是业务数据库中的待发命令;只确认平台受理,不能表示业务执行成功。
type Record struct {
NamespaceID string // NamespaceID 固定目标命名空间,换凭据不能改变归属。
QueueID string // QueueID 固定目标队列,发送前通过当前凭据核对归属。
OperationID string // OperationID 调用方持久化的业务操作ID,同命名空间内唯一。
IdempotencyKey string // IdempotencyKey 由操作ID派生的固定提交键,重报禁止换键。
RequestJSON []byte // RequestJSON 冻结的任务提交正文,保留数字精度,不含凭据。
RequestDigest []byte // RequestDigest 冻结正文的SHA256,发送前重新校验,非平台语义摘要。
State string // State PENDING待发、SENDING领取中、ACCEPTED已受理、REVIEW需核查。
SendCount uint32 // SendCount 持久化领取次数,包含领取后未发HTTP即退出的次数,上限100。
LeaseEpoch uint64 // LeaseEpoch 每次领取递增;旧代次不能更新新代次的本地结果。
WakeAt *time.Time // WakeAt PENDING的下次领取时间或SENDING的租约期限;终结状态为空,UTC微秒。
SendDeadlineAt time.Time // SendDeadlineAt 首次入库后24小时停止新发送,短于平台90天幂等窗口。
LastCode string // LastCode 最近一次稳定分类,不保存HTTP错误正文、凭据或业务秘密。
TaskID *string // TaskID 仅ACCEPTED记录平台任务ID,其余状态为空。
ReceiptJSON []byte // ReceiptJSON 仅ACCEPTED保存原受理回执;不是当前执行状态。
CreatedAt time.Time // CreatedAt 业务事务首次提交命令的UTC时间,微秒精度。
UpdatedAt time.Time // UpdatedAt 最近状态变化的UTC时间,不能替代租约代次。
}
adapter "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
)
// Record 复用公共适配器的中文持久化模型。
type Record = adapter.Record
// Job 演示接入方业务记录,与Record原子创建,受理确认时再原子关联任务。
type Job struct {

View File

@@ -1,36 +1,3 @@
-- 业务方独立数据库的示例表,不属于平台迁移,不创建外键或级联删除。
CREATE TABLE sender_outbox (
namespace_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定目标命名空间,重启及换凭据不得改变',
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定目标队列,发送前核对当前授权及命名空间',
operation_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '业务操作标识,同命名空间内唯一,调用方持久化后复用',
idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '冻结平台提交键,由业务操作标识派生,禁止换键补发',
request_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '冻结任务提交正文,保留精确数字,不存认证凭据',
request_digest BINARY(32) NOT NULL COMMENT '冻结正文SHA256,防止同身份不同内容,非平台摘要算法',
state VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING待发,SENDING领取中,ACCEPTED已受理,REVIEW需核查',
send_count INT UNSIGNED NOT NULL COMMENT '领取次数,包含领取后崩溃的次数,最多100',
lease_epoch BIGINT UNSIGNED NOT NULL COMMENT '领取代次递增,旧发送器不能覆盖新代次结果',
wake_at DATETIME(6) NULL COMMENT '待发下次时间或发送租约期限,终结后为空,UTC微秒',
send_deadline_at DATETIME(6) NOT NULL COMMENT '首次入库24小时后的发送截止,短于平台幂等保留窗口',
last_code VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '最近稳定分类,不保存服务端错误正文或秘密',
task_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '已确认的平台任务标识,只在ACCEPTED非空',
receipt_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '原受理回执,仅ACCEPTED非空,不代表当前执行结果',
created_at DATETIME(6) NOT NULL COMMENT '业务事务首次创建命令UTC时间,微秒精度',
updated_at DATETIME(6) NOT NULL COMMENT '本地状态最近变化UTC时间,微秒精度',
PRIMARY KEY (namespace_id,operation_id),
UNIQUE KEY uq_sender_identity(namespace_id,idempotency_key),
KEY ix_sender_due(namespace_id,queue_id,wake_at,operation_id),
CONSTRAINT ck_sender_document CHECK (JSON_VALID(request_json)),
CONSTRAINT ck_sender_receipt CHECK (receipt_json IS NULL OR JSON_VALID(receipt_json)),
CONSTRAINT ck_sender_count CHECK (send_count<=100 AND lease_epoch=send_count),
CONSTRAINT ck_sender_state CHECK (
(state IN ('PENDING','SENDING') AND wake_at IS NOT NULL AND task_id IS NULL AND receipt_json IS NULL)
OR (state='REVIEW' AND wake_at IS NULL AND task_id IS NULL AND receipt_json IS NULL)
OR (state='ACCEPTED' AND wake_at IS NULL AND task_id IS NOT NULL AND receipt_json IS NOT NULL)
),
CONSTRAINT ck_sender_lease CHECK (state<>'SENDING' OR lease_epoch>0),
CONSTRAINT ck_sender_deadline CHECK (send_deadline_at>created_at AND send_deadline_at<=TIMESTAMPADD(HOUR,24,created_at))
) ENGINE=InnoDB COMMENT='业务事务Outbox,持久化原请求与有限重发状态,不承担业务恰好一次';
CREATE TABLE sender_jobs (
namespace_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '与Outbox相同的命名空间范围',
operation_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '本地业务操作标识,与Outbox同事务创建',

View File

@@ -1,321 +1,95 @@
// Package outbox 演示接入方本地业务事务、稳定提交身份及有限发送恢复。
// Package outbox 演示在业务事务中使用公共 MySQL Outbox 适配包。
package outbox
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
adapter "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"github.com/go-sql-driver/mysql"
)
//go:embed schema.sql
var schema string
var (
ErrInvalid = errors.New("outbox scope, operation or request is invalid")
ErrConflict = errors.New("outbox operation already has different content; keep the original identity")
ErrStorage = errors.New("outbox storage failed; reconcile the original operation")
ErrCommitUnconfirmed = errors.New("outbox commit is unconfirmed; reconcile the original operation")
ErrIdle = errors.New("no due outbox record")
ErrNotFound = errors.New("outbox operation not found in this scope")
ErrLeaseLost = errors.New("outbox lease was replaced; do not overwrite its result")
idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
ErrInvalid = adapter.ErrInvalid
ErrStorage = adapter.ErrStorage
ErrConflict = adapter.ErrConflict
ErrCommitUnconfirmed = adapter.ErrCommitUnconfirmed
ErrIdle = adapter.ErrIdle
)
const maxSends = 100
const recordColumns = `namespace_id,queue_id,operation_id,idempotency_key,request_json,request_digest,state,send_count,lease_epoch,wake_at,send_deadline_at,last_code,task_id,receipt_json,created_at,updated_at`
// Store 仅补充示例业务事务;发送、查询、租约由公共包负责。
type Store struct {
db *sql.DB
namespace, queue string
*adapter.Store
db *sql.DB
namespace string
}
// Open 仅连接业务方数据库,不创建表,不输出原始DSN或数据库错误。
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil || cfg.DBName == "" {
return nil, ErrInvalid
}
cfg.ParseTime = true
cfg.Loc = time.UTC
cfg.MultiStatements = false
cfg.AllowAllFiles = false
cfg.Timeout, cfg.ReadTimeout, cfg.WriteTimeout = 5*time.Second, 8*time.Second, 8*time.Second
if cfg.Params == nil {
cfg.Params = map[string]string{}
}
cfg.Params["time_zone"] = "'+00:00'"
connector, err := mysql.NewConnector(cfg)
if err != nil {
return nil, ErrStorage
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(5 * time.Minute)
if err = db.PingContext(ctx); err != nil {
db.Close()
return nil, ErrStorage
}
return db, nil
}
var Open = adapter.Open
// Init 仅用于专用空库,已有表会报错;生产接入使用业务方自己的迁移。
func Init(ctx context.Context, db *sql.DB) error {
for _, statement := range strings.Split(schema, ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err := db.ExecContext(ctx, statement); err != nil {
return ErrStorage
}
if err := adapter.Init(ctx, db); err != nil {
return err
}
if _, err := db.ExecContext(ctx, schema); err != nil {
return ErrStorage
}
return nil
}
func New(db *sql.DB, namespace, queue string) (*Store, error) {
if db == nil || !idPattern.MatchString(namespace) || !idPattern.MatchString(queue) {
return nil, ErrInvalid
s, err := adapter.New(db, namespace, queue, adapter.Options{OnAccepted: accepted})
if err != nil {
return nil, err
}
return &Store{db: db, namespace: namespace, queue: queue}, nil
return &Store{s, db, namespace}, nil
}
// document 冻结当前v1默认值及UTC表示;Outbox按文档字节去重,不冒充平台语义摘要。
func (s *Store) document(in orbit.SubmitTaskRequest) ([]byte, error) {
// encoding/json会把非法UTF-8替换成U+FFFD;在冻结摘要前拒绝,不能静默改变业务标识。
for _, value := range []string{in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, in.BusinessKey, in.ConcurrencyKey} {
if !utf8.ValidString(value) {
return nil, ErrInvalid
}
func accepted(ctx context.Context, tx *sql.Tx, r adapter.Record, receipt orbit.SubmitTaskResult) error {
var existing *string
if err := tx.QueryRowContext(ctx, `SELECT task_id FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, r.NamespaceID, r.OperationID).Scan(&existing); err != nil {
return ErrStorage
}
if in.QueueID != s.queue || in.Handler == "" || in.HandlerVersion == "" || in.PayloadVersion == "" || len(in.Payload) > 256<<10 || !utf8.Valid(in.Payload) || !json.Valid(in.Payload) || bytes.Equal(bytes.TrimSpace(in.Payload), []byte("null")) {
return nil, ErrInvalid
if existing != nil && *existing != receipt.Task.ID {
return ErrConflict
}
if in.TaskTimeoutMS == 0 {
in.TaskTimeoutMS = 86400000
_, err := tx.ExecContext(ctx, `UPDATE sender_jobs SET task_id=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, receipt.Task.ID, r.NamespaceID, r.OperationID)
if err != nil {
return ErrStorage
}
if in.RecoverySafety == "" {
in.RecoverySafety = orbit.RecoveryReconcileOnly
}
if in.Retry.MaxAttempts == 0 {
in.Retry.MaxAttempts = 1
}
if in.Retry.AttemptTimeoutMS == 0 {
in.Retry.AttemptTimeoutMS = 30000
}
if in.Retry.MaxAttempts > 1 {
if in.Retry.InitialDelayMS == 0 {
in.Retry.InitialDelayMS = 1000
}
if in.Retry.MaxDelayMS == 0 {
in.Retry.MaxDelayMS = 30000
}
}
if in.AvailableAt != nil {
at := in.AvailableAt.UTC()
in.AvailableAt = &at
}
if in.StartDeadlineAt != nil {
at := in.StartDeadlineAt.UTC()
in.StartDeadlineAt = &at
}
raw, err := json.Marshal(in)
if err != nil || len(raw) > 512<<10 {
return nil, ErrInvalid
}
return raw, nil
return nil
}
// Enqueue 原子创建业务记录及Outbox。duplicate只表示该本地事务已经提交,不表示平台受理。
// 调用方在第一次调用前持久化operationID;COMMIT回执未知时仍用原ID、原内容再次调用。
func (s *Store) Enqueue(parent context.Context, operationID, sourceRef string, in orbit.SubmitTaskRequest) (duplicate bool, err error) {
if !idPattern.MatchString(operationID) || !utf8.ValidString(sourceRef) || strings.TrimSpace(sourceRef) == "" || utf8.RuneCountInString(sourceRef) > 512 || strings.ContainsFunc(sourceRef, unicode.IsControl) {
func (s *Store) Enqueue(ctx context.Context, id, source string, in orbit.SubmitTaskRequest) (bool, error) {
if !utf8.ValidString(source) || strings.TrimSpace(source) == "" || utf8.RuneCountInString(source) > 512 || strings.ContainsFunc(source, unicode.IsControl) {
return false, ErrInvalid
}
raw, err := s.document(in)
if err != nil {
return false, err
}
digest := sha256.Sum256(raw)
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return false, ErrStorage
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO sender_outbox(namespace_id,queue_id,operation_id,idempotency_key,request_json,request_digest,state,send_count,lease_epoch,wake_at,send_deadline_at,last_code,created_at,updated_at) VALUES(?,?,?,?,?,?,'PENDING',0,0,UTC_TIMESTAMP(6),TIMESTAMPADD(HOUR,24,UTC_TIMESTAMP(6)),'',UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, s.namespace, s.queue, operationID, "outbox-task-"+operationID, raw, digest[:])
duplicate, err := s.Store.EnqueueTx(ctx, tx, id, in)
if err != nil {
var mysqlErr *mysql.MySQLError
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1062 {
return false, err
}
if duplicate {
var saved string
if err = tx.QueryRowContext(ctx, `SELECT source_ref FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, s.namespace, id).Scan(&saved); err != nil {
return false, ErrStorage
}
if tx.Rollback() != nil {
return false, ErrStorage
}
var saved []byte
var queue, source string
err = s.db.QueryRowContext(ctx, `SELECT o.request_digest,o.queue_id,j.source_ref FROM sender_outbox o JOIN sender_jobs j ON j.namespace_id=o.namespace_id AND j.operation_id=o.operation_id WHERE o.namespace_id=? AND o.operation_id=?`, s.namespace, operationID).Scan(&saved, &queue, &source)
if err != nil {
return false, ErrStorage
}
if queue != s.queue || source != sourceRef || !bytes.Equal(saved, digest[:]) {
if saved != source {
return false, ErrConflict
}
return true, nil
}
// 锁序统一为Outbox→业务行,不能在业务行持锁时反向调用发送确认。
if _, err = tx.ExecContext(ctx, `INSERT INTO sender_jobs(namespace_id,operation_id,source_ref,created_at,updated_at) VALUES(?,?,?,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, s.namespace, operationID, sourceRef); err != nil {
return false, ErrStorage
} else {
if _, err = tx.ExecContext(ctx, `INSERT INTO sender_jobs(namespace_id,operation_id,source_ref,created_at,updated_at) VALUES(?,?,?,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, s.namespace, id, source); err != nil {
return false, ErrStorage
}
}
if tx.Commit() != nil {
return false, ErrCommitUnconfirmed
}
return false, nil
}
type scanner interface{ Scan(...any) error }
func scanRecord(row scanner, r *Record, extra ...any) error {
args := []any{&r.NamespaceID, &r.QueueID, &r.OperationID, &r.IdempotencyKey, &r.RequestJSON, &r.RequestDigest, &r.State, &r.SendCount, &r.LeaseEpoch, &r.WakeAt, &r.SendDeadlineAt, &r.LastCode, &r.TaskID, &r.ReceiptJSON, &r.CreatedAt, &r.UpdatedAt}
return row.Scan(append(args, extra...)...)
}
func (s *Store) Status(parent context.Context, id string) (Record, error) {
if !idPattern.MatchString(id) {
return Record{}, ErrInvalid
}
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
var r Record
err := scanRecord(s.db.QueryRowContext(ctx, `SELECT `+recordColumns+` FROM sender_outbox WHERE namespace_id=? AND queue_id=? AND operation_id=?`, s.namespace, s.queue, id), &r)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, ErrStorage
}
return r, nil
}
func (s *Store) claim(parent context.Context) (Record, error) {
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return Record{}, ErrStorage
}
defer tx.Rollback()
var r Record
var now time.Time
err = scanRecord(tx.QueryRowContext(ctx, `SELECT `+recordColumns+`,UTC_TIMESTAMP(6) FROM sender_outbox FORCE INDEX(ix_sender_due) WHERE namespace_id=? AND queue_id=? AND wake_at<=UTC_TIMESTAMP(6) ORDER BY wake_at,operation_id LIMIT 1 FOR UPDATE SKIP LOCKED`, s.namespace, s.queue), &r, &now)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrIdle
}
if err != nil {
return Record{}, ErrStorage
}
if r.State != "PENDING" && r.State != "SENDING" {
return Record{}, ErrStorage
}
if !now.Before(r.SendDeadlineAt) || r.SendCount >= maxSends {
r.State = "REVIEW"
r.WakeAt = nil
r.LastCode = "SEND_BUDGET_EXHAUSTED"
if !now.Before(r.SendDeadlineAt) {
r.LastCode = "SEND_DEADLINE"
}
} else {
r.State = "SENDING"
r.SendCount++
r.LeaseEpoch++
wake := now.Add(30 * time.Second)
if wake.After(r.SendDeadlineAt) {
wake = r.SendDeadlineAt
}
r.WakeAt = &wake
}
_, err = tx.ExecContext(ctx, `UPDATE sender_outbox SET state=?,send_count=?,lease_epoch=?,wake_at=?,last_code=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, r.State, r.SendCount, r.LeaseEpoch, r.WakeAt, r.LastCode, s.namespace, r.OperationID)
if err != nil {
return Record{}, ErrStorage
}
if tx.Commit() != nil {
return Record{}, ErrCommitUnconfirmed
}
return r, nil
}
// finish 只接受最近代次;过期但尚未被替代的成功回执可落库,重复HTTP只复用原键。
func (s *Store) finish(parent context.Context, claim Record, state, code string, delay time.Duration, receipt *orbit.SubmitTaskResult) error {
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return ErrStorage
}
defer tx.Rollback()
var current Record
var now time.Time
err = scanRecord(tx.QueryRowContext(ctx, `SELECT `+recordColumns+`,UTC_TIMESTAMP(6) FROM sender_outbox WHERE namespace_id=? AND queue_id=? AND operation_id=? FOR UPDATE`, s.namespace, s.queue, claim.OperationID), &current, &now)
if err != nil {
return ErrStorage
}
if current.State != "SENDING" || current.LeaseEpoch != claim.LeaseEpoch {
return ErrLeaseLost
}
var wake *time.Time
var task *string
var raw []byte
if state == "ACCEPTED" {
if receipt == nil || !validReceipt(current, *receipt) {
return ErrInvalid
}
raw, err = json.Marshal(receipt)
if err != nil || len(raw) > 1<<20 {
return ErrInvalid
}
task = &receipt.Task.ID
var existing *string
if err = tx.QueryRowContext(ctx, `SELECT task_id FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, s.namespace, current.OperationID).Scan(&existing); err != nil {
return ErrStorage
}
if existing != nil && *existing != *task {
return ErrConflict
}
if _, err = tx.ExecContext(ctx, `UPDATE sender_jobs SET task_id=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, *task, s.namespace, current.OperationID); err != nil {
return ErrStorage
}
} else if state == "PENDING" {
if delay < time.Second || delay > time.Hour {
return ErrInvalid
}
if !now.Before(current.SendDeadlineAt) || current.SendCount >= maxSends {
state = "REVIEW"
code = "SEND_BUDGET_EXHAUSTED"
} else {
next := now.Add(delay)
if next.After(current.SendDeadlineAt) {
next = current.SendDeadlineAt
}
wake = &next
}
} else if state != "REVIEW" {
return ErrInvalid
}
if _, err = tx.ExecContext(ctx, `UPDATE sender_outbox SET state=?,last_code=?,wake_at=?,task_id=?,receipt_json=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, state, code, wake, task, raw, s.namespace, current.OperationID); err != nil {
return ErrStorage
}
if tx.Commit() != nil {
return ErrCommitUnconfirmed
}
return nil
return duplicate, nil
}

3
go.mod
View File

@@ -7,9 +7,10 @@ require (
github.com/go-sql-driver/mysql v1.10.1
github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/crypto v0.48.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

6
go.sum
View File

@@ -10,5 +10,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

View File

@@ -1,6 +1,7 @@
go 1.27.1
use (
.
./sdk/go
.
./adapters/outbox/mysql
./sdk/go
)

1
go.work.sum Normal file
View File

@@ -0,0 +1 @@
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0/go.mod h1:9KlMW7Gy+EGaDoEwr3wHHFY47mnAymSfYeQpMS7h4ag=

View File

@@ -3,13 +3,22 @@ package config
import (
"errors"
"fmt"
core "git.yuntuo.hk/k2safe/orbit/model"
"net"
"os"
"path/filepath"
"strconv"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
)
type Config struct {
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
HTTPAddr string
MySQLDSN string
MaxOpen int
@@ -29,6 +38,7 @@ type Config struct {
func Load() (Config, error) { return FromEnv(os.Getenv) }
func FromEnv(get func(string) string) (Config, error) {
c := Config{
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
HTTPAddr: get("ORBIT_HTTP_ADDR"),
MySQLDSN: get("ORBIT_MYSQL_DSN"),
MaxOpen: 20,
@@ -120,5 +130,30 @@ func FromEnv(get func(string) string) (Config, error) {
return Config{}, errors.New("invalid ORBIT_METRICS_ADDR")
}
}
if c.ArchiveRoot != "" || c.ArchiveStorageID != "" {
if !filepath.IsAbs(c.ArchiveRoot) || c.ArchiveStorageID.Validate() != nil {
return Config{}, errors.New("ORBIT_ARCHIVE_ROOT and ORBIT_ARCHIVE_STORAGE_ID must be configured together")
}
}
if raw := get("ORBIT_ARCHIVE_MAX_BYTES"); raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1<<20 || n > 1<<40 {
return Config{}, errors.New("ORBIT_ARCHIVE_MAX_BYTES must be 1MiB to 1TiB")
}
c.ArchiveMaxBytes = n
}
if raw := get("ORBIT_ARCHIVE_TICK_INTERVAL"); raw != "" {
d, err := time.ParseDuration(raw)
if err != nil || d < time.Second || d > time.Minute {
return Config{}, errors.New("ORBIT_ARCHIVE_TICK_INTERVAL must be 1s to 1m")
}
c.ArchiveTickInterval = d
}
var err error
c.RetentionDefaults, err = retention.FromEnv(get)
if err != nil {
return Config{}, err
}
return c, nil
}

View File

@@ -65,3 +65,28 @@ func TestMetricsAddressIsExplicitAndValidated(t *testing.T) {
}
}
}
func TestArchiveRuntimeRequiresBoundedPersistentVolumeConfig(t *testing.T) {
base := map[string]string{"ORBIT_MYSQL_DSN": "test"}
get := func(k string) string { return base[k] }
cfg, err := FromEnv(get)
if err != nil || cfg.ArchiveRoot != "" || cfg.ArchiveMaxBytes != 10<<30 {
t.Fatal(cfg, err)
}
base["ORBIT_ARCHIVE_ROOT"] = "/private/archive"
if _, err = FromEnv(get); err == nil {
t.Fatal("missing volume identity accepted")
}
base["ORBIT_ARCHIVE_STORAGE_ID"] = "11111111111111111111111111111111"
if _, err = FromEnv(get); err != nil {
t.Fatal(err)
}
for k, v := range map[string]string{"ORBIT_ARCHIVE_MAX_BYTES": "0", "ORBIT_ARCHIVE_TICK_INTERVAL": "100ms", "ORBIT_ARCHIVE_ROOT": "relative", "ORBIT_ARCHIVE_STORAGE_ID": "not-an-id"} {
old := base[k]
base[k] = v
if _, err = FromEnv(get); err == nil {
t.Fatal("bad archive config", k)
}
base[k] = old
}
}

View File

@@ -0,0 +1,31 @@
// Package consoleauth defines the small boundary between the HTTP console and
// the database-backed account/session implementation.
package consoleauth
import (
"context"
"time"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// User is the safe, browser-visible part of a console account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
}
// LoginResult contains the short-lived internal bearer and its browser session metadata.
type LoginResult struct {
User User
Token string
ExpiresAt time.Time
}
// Store is implemented by the production database adapter.
type Store interface {
Login(ctx context.Context, username, password, userAgent, remoteAddr string) (LoginResult, error)
ValidateSession(ctx context.Context, token auth.Token) error
RevokeSession(ctx context.Context, token auth.Token) error
}

View File

@@ -0,0 +1,43 @@
package retention
import (
"context"
"time"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// ArchiveInput 选择一类历史快照;每批最多100条根记录,游标绑定类别和策略版本。
type ArchiveInput struct {
Kind string `json:"kind" required:"true"` // Kind tasks、workflows、events、audit、deliveries、inbox 或 idempotency。
Limit int `json:"limit"` // Limit 根记录数量,默认20,最大100。
Cursor string `json:"cursor"` // Cursor 上一批返回的继续位置,空表示从最早记录开始。
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"` // ExpectedRevision 防止使用页面未确认的保留窗口导出。
}
// ArchiveSnapshot 是一个只读归档批次;SourceRetained始终为true,禁止把导出当作数据库清理。
type ArchiveSnapshot struct {
FormatVersion int `json:"format_version"` // FormatVersion 归档信封格式版本。
SHA256 string `json:"sha256"` // SHA256 对Content原始UTF-8字节的摘要,校验传输和文件完整性。
Content string `json:"content"` // Content 是原始 JSON 文本;下载时按 UTF-8 原样保存再核验 SHA256,不重新序列化。
}
// CandidatePage 描述按策略筛选的一批根记录,不宣称它们可以删除。
type CandidatePage struct {
Kind string `json:"kind"` // Kind 历史类型。
Cutoff time.Time `json:"cutoff"` // Cutoff 当前策略的UTC排除边界。
Count int `json:"count"` // Count 本批候选数量,不是全库数量。
More bool `json:"more"` // More 后面还有候选根记录。
}
// Preview 返回按各类保留窗口读取的有限候选数量,不加载业务正文。
type Preview struct {
Policy Policy `json:"policy"` // Policy 实际读取的版本和窗口。
SampleLimit int `json:"sample_limit"` // SampleLimit 每类候选的取样上限。
Items []CandidatePage `json:"items"` // Items 固定类别的候选样本。
}
type ArchiveStore interface {
PreviewRetention(context.Context, auth.Token) (Preview, error)
ExportRetention(context.Context, auth.Token, ArchiveInput) (ArchiveSnapshot, error)
}

445
internal/retention/job.go Normal file
View File

@@ -0,0 +1,445 @@
package retention
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
)
// ErrArchiveLimit 表示达到显式容量边界,源数据与已发布文件均保留。
var ErrArchiveLimit = errors.New("archive byte limit reached; source and saved progress retained")
// ErrArchiveBusy 表示原作业被另一个进程持锁,不允许并发写入。
var ErrArchiveBusy = errors.New("archive job is locked")
const maxJobBatches = 10000
const maxBatchFileBytes = 64 << 20
// JobSpec 是首次落盘后不可变的作业范围,不包含凭据、DSN 或任意 SQL。
type JobSpec struct {
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 唯一命名空间。
Kind string `json:"kind"` // Kind 固定的历史类别。
PolicyRevision uint64 `json:"policy_revision,string"` // PolicyRevision 已在控制台确认的策略版本,必须非零。
BatchSize int `json:"batch_size"` // BatchSize 每批根记录上限,1–100。
}
// JobManifest 为作业初始化清单;后续进度由连续且校验通过的批次文件重建。
type JobManifest struct {
FormatVersion int `json:"format_version"` // FormatVersion 当前为1。
JobID core.ID `json:"job_id"` // JobID 随机且持久化的作业身份,也是数据库导出审计的执行者标识。
CreatedAt time.Time `json:"created_at"` // CreatedAt 清单创建时间,UTC,不作为数据库截止时间。
Spec JobSpec `json:"spec"` // Spec 不可变范围,恢复时必须完全一致。
}
// JobBatch 是一个原子发布的归档批次,正文和摘要保持服务端原样。
type JobBatch struct {
FormatVersion int `json:"format_version"` // FormatVersion 当前为1。
JobID core.ID `json:"job_id"` // JobID 与清单一致。
Sequence int `json:"sequence"` // Sequence 从1开始的连续编号,缺批拒绝恢复。
PreviousSHA256 string `json:"previous_sha256"` // PreviousSHA256 首批绑定清单文件摘要,后续绑定前一批文件摘要。
InputCursor string `json:"input_cursor"` // InputCursor 此次导出使用的游标,首批为空。
Snapshot ArchiveSnapshot `json:"snapshot"` // Snapshot 包含原始正文与摘要。
}
// JobOptions 控制本次进程的工作量,重试时可显式提高上限,不改变作业身份。
type JobOptions struct {
JobID core.ID // JobID 周期作业预分配身份;空值保持CLI自动生成行为。
RequireExisting bool // RequireExisting 接管时必须存在原清单,禁止卷丢失后重建。
Checkpoint *JobStatus // Checkpoint 数据库已确认的链前缀,校验后才允许继续导出。
MaxBatches int // MaxBatches 本次最多新增批次数,1–1000。
MaxBytes int64 // MaxBytes 作业清单和全部批次文件总字节上限,1 MiB–100 GiB;不自动删除旧文件腾空间。
}
// JobStatus 是可输出到日志的摘要,不包含业务正文或游标。
type JobStatus struct {
JobID core.ID `json:"job_id"` // JobID 原作业标识,恢复不会生成新标识。
Completed bool `json:"completed"` // Completed 仅在末批成功持久化且游标为空时为真。
Batches int `json:"batches"` // Batches 已校验的连续文件数。
Roots int64 `json:"roots"` // Roots 已归档根记录总数,不包含关联历史行。
Bytes int64 `json:"bytes"` // Bytes 清单及批次文件实际总字节,不包含临时文件和锁。
LastSHA256 string `json:"last_sha256"` // LastSHA256 最新批次或初始清单的摘要,用于独立保存核对。
}
// ExportBatch 由运维调用方提供,数据库读取和授权仍由存储层完成。
type ExportBatch func(context.Context, core.ID, ArchiveInput) (ArchiveSnapshot, error)
func (s JobSpec) Validate() error {
if s.NamespaceID.Validate() != nil || s.PolicyRevision == 0 || s.BatchSize < 1 || s.BatchSize > 100 {
return errors.New("invalid archive namespace, revision or batch size")
}
switch s.Kind {
case "tasks", "workflows", "events", "audit", "deliveries", "inbox", "idempotency":
return nil
}
return errors.New("unsupported archive kind")
}
// RunArchive 自动处理有限批次,先验证全部已有文件再恢复。每个已发布文件就是检查点,
// 不存在“文件成功但内存游标尚未落盘”的跨资源提交窗口;崩溃留下的临时文件不计为成功。
func RunArchive(ctx context.Context, directory string, spec JobSpec, options JobOptions, export ExportBatch) (JobStatus, error) {
if err := spec.Validate(); err != nil {
return JobStatus{}, err
}
if options.MaxBatches < 1 || options.MaxBatches > 1000 || options.MaxBytes < 1<<20 || options.MaxBytes > 100<<30 || export == nil {
return JobStatus{}, errors.New("invalid archive execution limits")
}
if options.JobID != "" && options.JobID.Validate() != nil {
return JobStatus{}, errors.New("invalid archive job id")
}
root, lock, err := openJob(directory, !options.RequireExisting)
if err != nil {
return JobStatus{}, err
}
defer root.Close()
defer lock.Close()
if err := discardStaging(root); err != nil {
return JobStatus{}, err
}
if _, err := root.Lstat("manifest.json"); errors.Is(err, os.ErrNotExist) {
if options.RequireExisting {
return JobStatus{}, errors.New("saved archive manifest is missing")
}
// 不在未知目录或缺少清单的残留批次上悄悄创建新身份。
entries, err := jobEntries(root)
if err != nil || len(entries) != 1 || entries[0].Name() != ".lock" {
return JobStatus{}, errors.New("archive directory is not empty or manifest is missing")
}
id := options.JobID
if id == "" {
id, err = core.NewID()
if err != nil {
return JobStatus{}, err
}
}
manifest := JobManifest{1, id, time.Now().UTC(), spec}
raw, err := json.Marshal(manifest)
if err != nil {
return JobStatus{}, err
}
if err := publishFile(root, "manifest.json", raw); err != nil {
return JobStatus{}, err
}
} else if err != nil {
return JobStatus{}, err
}
manifest, status, cursor, cutoff, err := readJob(ctx, root, options.MaxBytes)
if err != nil {
return status, err
}
if manifest.Spec != spec || (options.JobID != "" && manifest.JobID != options.JobID) {
return status, errors.New("archive scope differs from saved manifest; resume with original arguments")
}
if saved := options.Checkpoint; saved != nil && saved.LastSHA256 != "" {
if saved.JobID != status.JobID || saved.Batches > status.Batches || saved.Roots > status.Roots || saved.Bytes > status.Bytes {
return status, errors.New("archive progress is behind database checkpoint")
}
name := "manifest.json"
if saved.Batches > 0 {
name = batchName(saved.Batches)
}
raw, err := readJobFile(root, name, maxBatchFileBytes)
if err != nil || digest(raw) != saved.LastSHA256 {
return status, errors.New("archive prefix differs from database checkpoint")
}
}
if err := syncJobDirectory(root); err != nil {
return status, err
}
for i := 0; i < options.MaxBatches && !status.Completed; i++ {
if err := ctx.Err(); err != nil {
return status, err
}
if status.Batches >= maxJobBatches {
return status, errors.New("archive batch count limit reached")
}
snapshot, err := export(ctx, manifest.JobID, ArchiveInput{Kind: spec.Kind, Limit: spec.BatchSize, Cursor: cursor, ExpectedRevision: spec.PolicyRevision})
if err != nil {
return status, err
}
packet := JobBatch{1, manifest.JobID, status.Batches + 1, status.LastSHA256, cursor, snapshot}
next, err := verifyBatch(manifest, packet, status, cursor, cutoff)
if err != nil {
return status, err
}
raw, err := json.Marshal(packet)
if err != nil {
return status, err
}
if len(raw) > maxBatchFileBytes || int64(len(raw)) > options.MaxBytes-status.Bytes {
return status, ErrArchiveLimit
}
if err := ctx.Err(); err != nil {
return status, err
}
if err := publishFile(root, batchName(packet.Sequence), raw); err != nil {
return status, err
}
// 重新读取原始字节核验,磁盘错误不能推进内存检查点或输出成功。
saved, err := readJobFile(root, batchName(packet.Sequence), maxBatchFileBytes)
if err != nil || !bytes.Equal(saved, raw) {
return status, errors.New("archive file readback failed; verify job before resuming")
}
advanceJob(&status, &cursor, &cutoff, next, raw)
}
return status, nil
}
// VerifyArchive 离线逐批校验整个作业,无需数据库或控制台会话;未到末批会返回 Completed=false。
func VerifyArchive(ctx context.Context, directory string) (JobStatus, error) {
root, lock, err := openJob(directory, false)
if err != nil {
return JobStatus{}, err
}
defer root.Close()
defer lock.Close()
_, status, _, _, err := readJob(ctx, root, 100<<30)
return status, err
}
func openJob(directory string, create bool) (*os.Root, *os.File, error) {
if !filepath.IsAbs(directory) {
return nil, nil, errors.New("archive directory must be an absolute path")
}
if create {
if err := os.Mkdir(directory, 0700); err == nil {
parent, err := os.Open(filepath.Dir(directory))
if err != nil {
return nil, nil, err
}
err = parent.Sync()
parent.Close()
if err != nil {
return nil, nil, err
}
} else if !errors.Is(err, os.ErrExist) {
return nil, nil, err
}
}
info, err := os.Lstat(directory)
if err != nil {
return nil, nil, err
}
if !info.IsDir() || info.Mode().Perm()&0077 != 0 {
return nil, nil, errors.New("archive directory must be a private directory (0700), not a symlink")
}
root, err := os.OpenRoot(directory)
if err != nil {
return nil, nil, err
}
lock, err := lockJob(root)
if err != nil {
root.Close()
return nil, nil, err
}
return root, lock, nil
}
func jobEntries(root *os.Root) ([]os.DirEntry, error) {
dir, err := root.Open(".")
if err != nil {
return nil, err
}
defer dir.Close()
entries, err := dir.ReadDir(maxJobBatches + 4)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
if len(entries) >= maxJobBatches+4 {
return nil, errors.New("archive directory contains too many files")
}
return entries, nil
}
func readJob(ctx context.Context, root *os.Root, maxBytes int64) (JobManifest, JobStatus, string, time.Time, error) {
var manifest JobManifest
var status JobStatus
var cursor string
var cutoff time.Time
fail := func(err error) (JobManifest, JobStatus, string, time.Time, error) {
return manifest, status, cursor, cutoff, err
}
raw, err := readJobFile(root, "manifest.json", 65536)
if err != nil {
return fail(err)
}
if decodeJobJSON(raw, &manifest) != nil || manifest.FormatVersion != 1 || manifest.JobID.Validate() != nil || manifest.CreatedAt.IsZero() || manifest.Spec.Validate() != nil {
return fail(errors.New("invalid archive manifest"))
}
status.JobID, status.Bytes, status.LastSHA256 = manifest.JobID, int64(len(raw)), digest(raw)
entries, err := jobEntries(root)
if err != nil {
return fail(err)
}
files := make(map[string]bool, len(entries))
for _, e := range entries {
files[e.Name()] = true
}
delete(files, ".lock")
delete(files, "manifest.json")
// 临时文件不属于已发布链,离线校验不会删除它;恢复运行时再清理。
if files[".write.tmp"] {
if _, err := readJobFile(root, ".write.tmp", maxBatchFileBytes); err != nil {
return fail(err)
}
delete(files, ".write.tmp")
}
seen := map[string]bool{}
for files[batchName(status.Batches+1)] {
if err := ctx.Err(); err != nil {
return fail(err)
}
name := batchName(status.Batches + 1)
raw, err := readJobFile(root, name, maxBatchFileBytes)
if err != nil {
return fail(err)
}
if int64(len(raw)) > maxBytes-status.Bytes {
return fail(ErrArchiveLimit)
}
var packet JobBatch
if decodeJobJSON(raw, &packet) != nil {
return fail(errors.New("invalid archive batch JSON"))
}
next, err := verifyBatch(manifest, packet, status, cursor, cutoff)
if err != nil {
return fail(err)
}
if next.NextCursor != "" && seen[next.NextCursor] {
return fail(errors.New("archive cursor cycle detected"))
}
seen[next.NextCursor] = true
advanceJob(&status, &cursor, &cutoff, next, raw)
delete(files, name)
}
if len(files) != 0 {
return fail(errors.New("archive has missing batches or unexpected files"))
}
return manifest, status, cursor, cutoff, nil
}
type archivePage struct {
Policy Policy `json:"policy"` // Policy 本批数据库策略。
Kind string `json:"kind"` // Kind 本批根类别。
Cutoff time.Time `json:"cutoff"` // Cutoff 所有批次冻结的UTC边界。
RootCount int `json:"root_count"` // RootCount 本批根数量。
SourceRetained bool `json:"source_retained"` // SourceRetained 必须为真。
NextCursor string `json:"next_cursor"` // NextCursor 空表示末批。
}
func verifyBatch(m JobManifest, b JobBatch, status JobStatus, cursor string, cutoff time.Time) (archivePage, error) {
var page archivePage
if status.Completed || b.FormatVersion != 1 || b.JobID != m.JobID || b.Sequence != status.Batches+1 || b.PreviousSHA256 != status.LastSHA256 || b.InputCursor != cursor || b.Snapshot.FormatVersion != 1 || len(b.Snapshot.Content) > 8<<20 || digest([]byte(b.Snapshot.Content)) != b.Snapshot.SHA256 {
return page, errors.New("archive batch identity or digest mismatch")
}
if json.Unmarshal([]byte(b.Snapshot.Content), &page) != nil || page.Policy.NamespaceID != m.Spec.NamespaceID || page.Policy.Revision != m.Spec.PolicyRevision || !page.Policy.Enabled || page.Kind != m.Spec.Kind || !page.SourceRetained || page.Cutoff.IsZero() || (!cutoff.IsZero() && !page.Cutoff.Equal(cutoff)) || page.RootCount < 0 || page.RootCount > m.Spec.BatchSize || len(page.NextCursor) > 2048 || (page.NextCursor != "" && (page.NextCursor == cursor || page.RootCount == 0)) {
return page, errors.New("archive batch scope or continuation mismatch")
}
return page, nil
}
func advanceJob(status *JobStatus, cursor *string, cutoff *time.Time, page archivePage, raw []byte) {
status.Batches++
status.Roots += int64(page.RootCount)
status.Bytes += int64(len(raw))
status.LastSHA256 = digest(raw)
status.Completed = page.NextCursor == ""
*cursor, *cutoff = page.NextCursor, page.Cutoff
}
func digest(raw []byte) string { sum := sha256.Sum256(raw); return hex.EncodeToString(sum[:]) }
func batchName(n int) string { return fmt.Sprintf("batch-%06d.json", n) }
func decodeJobJSON(raw []byte, value any) error {
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if err := d.Decode(value); err != nil {
return err
}
if err := d.Decode(new(any)); err != io.EOF {
return errors.New("trailing archive JSON")
}
return nil
}
func readJobFile(root *os.Root, name string, limit int64) ([]byte, error) {
info, err := root.Lstat(name)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > limit {
return nil, errors.New("archive file must be bounded, regular and private (0600)")
}
f, err := root.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, err
}
if int64(len(raw)) > limit {
return nil, errors.New("archive file too large")
}
return raw, nil
}
func discardStaging(root *os.Root) error {
if _, err := root.Lstat(".write.tmp"); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if _, err := readJobFile(root, ".write.tmp", maxBatchFileBytes); err != nil {
return err
}
return root.Remove(".write.tmp")
}
// publishFile 先写私有临时文件并 fsync,再以硬链接原子发布(拒绝覆盖),最后同步目录。
// 仅依赖本地 POSIX 文件系统语义;网络文件系统和多节点共享盘须独立验证。
func publishFile(root *os.Root, name string, raw []byte) error {
f, err := root.OpenFile(".write.tmp", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer root.Remove(".write.tmp")
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
err = f.Sync()
closeErr := f.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err := root.Link(".write.tmp", name); err != nil {
return err
}
if err := root.Remove(".write.tmp"); err != nil {
return err
}
return syncJobDirectory(root)
}
func syncJobDirectory(root *os.Root) error {
dir, err := root.Open(".")
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}

View File

@@ -0,0 +1,12 @@
//go:build !linux && !darwin
package retention
import (
"errors"
"os"
)
func lockJob(root *os.Root) (*os.File, error) {
return nil, errors.New("archive file jobs require Linux or macOS POSIX locking")
}

View File

@@ -0,0 +1,30 @@
//go:build linux || darwin
package retention
import (
"errors"
"os"
"syscall"
)
// lockJob 的非阻塞文件锁随进程退出自动释放,不使用永不过期的“锁目录”。
func lockJob(root *os.Root) (*os.File, error) {
f, err := root.OpenFile(".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
f.Close()
return nil, errors.New("invalid archive lock file")
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return nil, ErrArchiveBusy
}
return nil, errors.New("archive filesystem does not support locking")
}
return f, nil
}

View File

@@ -0,0 +1,291 @@
package retention
import (
"bufio"
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func testJobSpec() JobSpec {
return JobSpec{NamespaceID: "11111111111111111111111111111111", Kind: "tasks", PolicyRevision: 1, BatchSize: 1}
}
func testJobOptions() JobOptions { return JobOptions{MaxBatches: 10, MaxBytes: 16 << 20} }
func testJobSnapshot(t *testing.T, cursor string, payload int) ArchiveSnapshot {
t.Helper()
p := Defaults(testJobSpec().NamespaceID)
p.Enabled = true
p.Revision = 1
next := "next-page"
if cursor != "" {
next = ""
}
raw, err := json.Marshal(map[string]any{"policy": p, "kind": "tasks", "cutoff": time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), "root_count": 1, "source_retained": true, "next_cursor": next, "payload": strings.Repeat("x", payload)})
if err != nil {
t.Fatal(err)
}
return ArchiveSnapshot{1, digest(raw), string(raw)}
}
func testJobExporter(t *testing.T, calls *int) ExportBatch {
return func(ctx context.Context, job core.ID, in ArchiveInput) (ArchiveSnapshot, error) {
*calls++
if job.Validate() != nil || in.ExpectedRevision != 1 || in.Kind != "tasks" || in.Limit != 1 {
t.Fatal("export request changed identity")
}
return testJobSnapshot(t, in.Cursor, 0), nil
}
}
func TestArchiveJobResumeAndOfflineVerification(t *testing.T) {
dir := filepath.Join(t.TempDir(), "job")
calls := 0
options := testJobOptions()
options.MaxBatches = 1
first, err := RunArchive(context.Background(), dir, testJobSpec(), options, testJobExporter(t, &calls))
if err != nil || first.Completed || first.Roots != 1 || first.Batches != 1 {
t.Fatalf("first=%+v err=%v", first, err)
}
// 模拟强杀前只写了一部分临时文件;它不是有效检查点。
if err := os.WriteFile(filepath.Join(dir, ".write.tmp"), []byte(`{"unfinished":`), 0600); err != nil {
t.Fatal(err)
}
second, err := RunArchive(context.Background(), dir, testJobSpec(), options, testJobExporter(t, &calls))
if err != nil || !second.Completed || second.Roots != 2 || second.Batches != 2 || second.JobID != first.JobID || calls != 2 {
t.Fatalf("resume=%+v calls=%d err=%v", second, calls, err)
}
verified, err := VerifyArchive(context.Background(), dir)
if err != nil || verified != second {
t.Fatalf("verify=%+v err=%v", verified, err)
}
repeated, err := RunArchive(context.Background(), dir, testJobSpec(), options, testJobExporter(t, &calls))
if err != nil || repeated != second || calls != 2 {
t.Fatalf("completed job re-exported: %+v err=%v", repeated, err)
}
spec := testJobSpec()
spec.PolicyRevision++
if _, err := RunArchive(context.Background(), dir, spec, options, testJobExporter(t, &calls)); err == nil || calls != 2 {
t.Fatal("changed scope accepted")
}
for _, name := range []string{"manifest.json", "batch-000001.json", "batch-000002.json"} {
info, err := os.Stat(filepath.Join(dir, name))
if err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("file permissions %s: %v", name, err)
}
}
}
func TestArchiveJobRejectsDamagedChain(t *testing.T) {
for _, kind := range []string{"body", "missing", "swapped", "symlink", "unexpected"} {
t.Run(kind, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "job")
calls := 0
if _, err := RunArchive(context.Background(), dir, testJobSpec(), testJobOptions(), testJobExporter(t, &calls)); err != nil {
t.Fatal(err)
}
first := filepath.Join(dir, "batch-000001.json")
second := filepath.Join(dir, "batch-000002.json")
var err error
switch kind {
case "body":
raw, _ := os.ReadFile(first)
var packet JobBatch
if err = json.Unmarshal(raw, &packet); err != nil {
t.Fatal(err)
}
packet.Snapshot.Content += " "
raw, _ = json.Marshal(packet)
err = os.WriteFile(first, raw, 0600)
case "missing":
err = os.Remove(first)
case "swapped":
raw, _ := os.ReadFile(second)
err = os.WriteFile(first, raw, 0600)
case "symlink":
if err = os.Remove(first); err == nil {
err = os.Symlink(second, first)
}
case "unexpected":
err = os.WriteFile(filepath.Join(dir, "unrelated.json"), []byte("{}"), 0600)
}
if err != nil {
t.Fatal(err)
}
if _, err := VerifyArchive(context.Background(), dir); err == nil {
t.Fatal("damaged archive accepted")
}
if _, err := RunArchive(context.Background(), dir, testJobSpec(), testJobOptions(), testJobExporter(t, &calls)); err == nil || calls != 2 {
t.Fatalf("resume skipped corrupt history: calls=%d err=%v", calls, err)
}
})
}
}
func TestArchiveJobQuotaFailureAndCancellationPreserveProgress(t *testing.T) {
dir := filepath.Join(t.TempDir(), "quota")
options := testJobOptions()
options.MaxBytes = 1 << 20
calls := 0
export := func(ctx context.Context, id core.ID, in ArchiveInput) (ArchiveSnapshot, error) {
calls++
return testJobSnapshot(t, in.Cursor, 700<<10), nil
}
status, err := RunArchive(context.Background(), dir, testJobSpec(), options, export)
if err == nil || status.Completed || status.Batches != 1 {
t.Fatalf("quota=%+v err=%v", status, err)
}
first, _ := os.ReadFile(filepath.Join(dir, "batch-000001.json"))
options.MaxBytes = 4 << 20
final, err := RunArchive(context.Background(), dir, testJobSpec(), options, export)
if err != nil || !final.Completed || final.Batches != 2 || calls != 3 {
t.Fatalf("quota resume=%+v calls=%d err=%v", final, calls, err)
}
after, _ := os.ReadFile(filepath.Join(dir, "batch-000001.json"))
if string(first) != string(after) {
t.Fatal("prior file overwritten")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
status, err = RunArchive(ctx, filepath.Join(t.TempDir(), "cancel"), testJobSpec(), testJobOptions(), func(ctx context.Context, id core.ID, in ArchiveInput) (ArchiveSnapshot, error) {
cancel()
return testJobSnapshot(t, "", 0), nil
})
if !errors.Is(err, context.Canceled) || status.Batches != 0 {
t.Fatalf("cancellation advanced progress: %+v %v", status, err)
}
}
func TestArchiveJobExclusiveLock(t *testing.T) {
dir := filepath.Join(t.TempDir(), "job")
root, lock, err := openJob(dir, true)
if err != nil {
t.Fatal(err)
}
defer root.Close()
defer lock.Close()
calls := 0
if _, err := RunArchive(context.Background(), dir, testJobSpec(), testJobOptions(), testJobExporter(t, &calls)); err == nil || calls != 0 {
t.Fatal("parallel writer accepted")
}
}
func TestArchiveJobProcessKillAndResume(t *testing.T) {
if dir := os.Getenv("ORBIT_ARCHIVE_KILL_TEST_DIR"); dir != "" {
_, err := RunArchive(context.Background(), dir, testJobSpec(), testJobOptions(), func(ctx context.Context, id core.ID, in ArchiveInput) (ArchiveSnapshot, error) {
if in.Cursor != "" {
os.Stdout.WriteString("first-batch-durable\n")
select {}
}
return testJobSnapshot(t, "", 0), nil
})
if err != nil {
t.Fatal(err)
}
return
}
dir := filepath.Join(t.TempDir(), "job")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestArchiveJobProcessKillAndResume$")
cmd.Env = append(os.Environ(), "ORBIT_ARCHIVE_KILL_TEST_DIR="+dir)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() {
if cmd.ProcessState == nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() || scanner.Text() != "first-batch-durable" {
t.Fatal("child did not publish first batch")
}
if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err == nil {
t.Fatal("child was not killed")
}
// 等待旧进程真正退出后,新进程重新取得锁,只导出第二批。
calls := 0
status, err := RunArchive(context.Background(), dir, testJobSpec(), testJobOptions(), testJobExporter(t, &calls))
if err != nil || !status.Completed || status.Roots != 2 || status.Batches != 2 || calls != 1 {
t.Fatalf("killed-process recovery=%+v calls=%d err=%v", status, calls, err)
}
}
func TestArchiveJobWriteFailureDoesNotAdvance(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("permission failure requires an unprivileged process")
}
dir := filepath.Join(t.TempDir(), "job")
calls := 0
options := testJobOptions()
options.MaxBatches = 1
first, err := RunArchive(context.Background(), dir, testJobSpec(), options, testJobExporter(t, &calls))
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0500); err != nil {
t.Fatal(err)
}
defer os.Chmod(dir, 0700)
after, err := RunArchive(context.Background(), dir, testJobSpec(), options, testJobExporter(t, &calls))
if err == nil || after != first {
t.Fatalf("failed write advanced checkpoint: %+v err=%v", after, err)
}
verified, err := VerifyArchive(context.Background(), dir)
if err != nil || verified != first {
t.Fatalf("saved archive changed: %+v err=%v", verified, err)
}
if err := os.Chmod(dir, 0700); err != nil {
t.Fatal(err)
}
final, err := RunArchive(context.Background(), dir, testJobSpec(), options, testJobExporter(t, &calls))
if err != nil || !final.Completed || final.Roots != 2 {
t.Fatalf("write recovery=%+v err=%v", final, err)
}
}
func TestManagedArchiveChecksDatabasePrefixAndMissingVolume(t *testing.T) {
ctx := context.Background()
dir := filepath.Join(t.TempDir(), "job")
id, _ := core.NewID()
calls := 0
options := testJobOptions()
options.JobID = id
options.MaxBatches = 1
first, err := RunArchive(ctx, dir, testJobSpec(), options, testJobExporter(t, &calls))
if err != nil || first.JobID != id {
t.Fatal(first, err)
}
options.Checkpoint = &first
options.RequireExisting = true
forged := first
forged.LastSHA256 = strings.Repeat("0", 64)
options.Checkpoint = &forged
if _, err = RunArchive(ctx, dir, testJobSpec(), options, testJobExporter(t, &calls)); err == nil || calls != 1 {
t.Fatal("untrusted prefix accepted")
}
options.Checkpoint = &first
if _, err = RunArchive(ctx, filepath.Join(t.TempDir(), "missing"), testJobSpec(), options, testJobExporter(t, &calls)); err == nil || calls != 1 {
t.Fatal("missing volume silently recreated")
}
second, err := RunArchive(ctx, dir, testJobSpec(), options, testJobExporter(t, &calls))
if err != nil || !second.Completed || second.JobID != id {
t.Fatal(second, err)
}
}

View File

@@ -0,0 +1,192 @@
// Package retention 定义 Orbit 历史数据保留策略的通用模型。
//
// 策略只描述候选数据的最短保留窗口,不直接删除任务、事件或审计记录。
// 归档执行器必须在独立流程中先完成核查、备份和归档证明,再按策略处理。
package retention
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
const (
// DefaultIdempotencyDays 是请求幂等记录的默认保留天数。
DefaultIdempotencyDays uint32 = 90
// DefaultTaskHistoryDays 是终态任务历史的默认保留天数。
DefaultTaskHistoryDays uint32 = 365
// DefaultWorkflowHistoryDays 是终态流程历史的默认保留天数。
DefaultWorkflowHistoryDays uint32 = 365
// DefaultEventLogDays 是状态事件和结果事件的默认保留天数。
DefaultEventLogDays uint32 = 365
// DefaultDeliveryHistoryDays 是已确认交付历史的默认保留天数。
DefaultDeliveryHistoryDays uint32 = 90
// DefaultInboxHistoryDays 是已处理流程 Inbox 历史的默认保留天数。
DefaultInboxHistoryDays uint32 = 90
// DefaultAuditLogDays 是管理审计的默认保留天数,约七年。
DefaultAuditLogDays uint32 = 2555
minRetentionDays uint32 = 1
maxRetentionDays uint32 = 3650
)
// Policy 是按命名空间保存的历史数据保留策略。
// Enabled 默认关闭;即使打开,也允许只读归档导出使用满足窗口且已核验的数据。
// 当前版本不会因为配置变更自动删除任何数据。
type Policy struct {
// NamespaceID 是策略所属命名空间,不能由控制台请求替换。
NamespaceID core.ID `json:"namespace_id"`
// Revision 是乐观并发版本;默认策略未落库时为0,首次保存后从1开始。
Revision uint64 `json:"revision,string"`
// Enabled 表示是否允许归档执行器使用该策略;默认关闭以避免误清理。
Enabled bool `json:"enabled"`
// IdempotencyDays 是请求幂等记录至少保留的天数。
IdempotencyDays uint32 `json:"idempotency_days"`
// TaskHistoryDays 是终态任务及执行证据至少保留的天数。
TaskHistoryDays uint32 `json:"task_history_days"`
// WorkflowHistoryDays 是终态流程、步骤、等待和流程事件至少保留的天数。
WorkflowHistoryDays uint32 `json:"workflow_history_days"`
// EventLogDays 是状态事件和结果事件至少保留的天数。
EventLogDays uint32 `json:"event_log_days"`
// DeliveryHistoryDays 是已确认结果交付至少保留的天数;未确认交付不得按窗口清理。
DeliveryHistoryDays uint32 `json:"delivery_history_days"`
// InboxHistoryDays 是已处理流程 Inbox 事件至少保留的天数;未处理记录不得清理。
InboxHistoryDays uint32 `json:"inbox_history_days"`
// AuditLogDays 是管理审计至少保留的天数;审计记录不参与普通清理。
AuditLogDays uint32 `json:"audit_log_days"`
// UpdatedAt 是策略最后修改时间,统一使用 UTC。
UpdatedAt time.Time `json:"updated_at"`
// Source 表示策略来自默认值还是数据库配置,便于控制台明确当前状态。
Source string `json:"source"`
}
// UpdateInput 是控制台保存策略所需的完整替换输入。
// 所有窗口必须显式提交,避免遗漏字段被解释成缩短保留期。
type UpdateInput struct {
// ExpectedRevision 是页面读取到的版本;首次保存必须为0。
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"`
// Enabled 是否允许归档执行器读取本策略。
Enabled bool `json:"enabled" required:"true"`
// IdempotencyDays 请求幂等记录保留天数。
IdempotencyDays uint32 `json:"idempotency_days" required:"true"`
// TaskHistoryDays 终态任务历史保留天数。
TaskHistoryDays uint32 `json:"task_history_days" required:"true"`
// WorkflowHistoryDays 终态流程历史保留天数。
WorkflowHistoryDays uint32 `json:"workflow_history_days" required:"true"`
// EventLogDays 状态和结果事件保留天数。
EventLogDays uint32 `json:"event_log_days" required:"true"`
// DeliveryHistoryDays 已确认交付历史保留天数。
DeliveryHistoryDays uint32 `json:"delivery_history_days" required:"true"`
// InboxHistoryDays 已处理 Inbox 历史保留天数。
InboxHistoryDays uint32 `json:"inbox_history_days" required:"true"`
// AuditLogDays 管理审计保留天数。
AuditLogDays uint32 `json:"audit_log_days" required:"true"`
// Reason 是本次策略变更原因,会进入管理审计。
Reason string `json:"reason" required:"true"`
}
// Store 是控制台和归档执行器共享的最小策略存储边界。
// 实现必须保证 Update 的版本核对、幂等和审计与策略写入处于同一事务。
type Store interface {
GetRetentionPolicy(context.Context, core.ID) (Policy, error)
UpdateRetentionPolicy(context.Context, auth.Token, string, UpdateInput) (Policy, error)
}
// Defaults 返回不会触发任何归档执行的安全默认策略。
func Defaults(namespace core.ID) Policy {
return Policy{
NamespaceID: namespace,
Enabled: false,
IdempotencyDays: DefaultIdempotencyDays,
TaskHistoryDays: DefaultTaskHistoryDays,
WorkflowHistoryDays: DefaultWorkflowHistoryDays,
EventLogDays: DefaultEventLogDays,
DeliveryHistoryDays: DefaultDeliveryHistoryDays,
InboxHistoryDays: DefaultInboxHistoryDays,
AuditLogDays: DefaultAuditLogDays,
Source: "default",
}
}
// Validate 检查策略身份、窗口和审计原因。
func (p Policy) Validate() error {
if p.NamespaceID.Validate() != nil {
return errors.New("retention namespace is invalid")
}
for name, days := range map[string]uint32{
"idempotency_days": p.IdempotencyDays,
"task_history_days": p.TaskHistoryDays,
"workflow_history_days": p.WorkflowHistoryDays,
"event_log_days": p.EventLogDays,
"delivery_history_days": p.DeliveryHistoryDays,
"inbox_history_days": p.InboxHistoryDays,
"audit_log_days": p.AuditLogDays,
} {
if days < minRetentionDays || days > maxRetentionDays {
return fmt.Errorf("%s must be between %d and %d days", name, minRetentionDays, maxRetentionDays)
}
}
if p.IdempotencyDays < DefaultIdempotencyDays {
return errors.New("idempotency_days cannot be less than 90 days")
}
return nil
}
// ToPolicy 将完整输入转为待保存策略,版本由事务内分配。
func (in UpdateInput) ToPolicy(namespace core.ID) (Policy, error) {
if strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || len(in.Reason) > 1024 || strings.ContainsAny(in.Reason, "\r\n") {
return Policy{}, errors.New("retention reason must be 1-1024 UTF-8 bytes without newlines")
}
p := Policy{
NamespaceID: namespace,
Enabled: in.Enabled,
IdempotencyDays: in.IdempotencyDays,
TaskHistoryDays: in.TaskHistoryDays,
WorkflowHistoryDays: in.WorkflowHistoryDays,
EventLogDays: in.EventLogDays,
DeliveryHistoryDays: in.DeliveryHistoryDays,
InboxHistoryDays: in.InboxHistoryDays,
AuditLogDays: in.AuditLogDays,
}
if err := p.Validate(); err != nil {
return Policy{}, err
}
return p, nil
}
// FromEnv 读取部署默认值;数据库策略保存后优先于部署默认值。
// 所有节点应注入同一组默认值,保证尚未保存策略的展示一致。
func FromEnv(get func(string) string) (Policy, error) {
p := Defaults("")
for _, f := range []struct {
name string
dst *uint32
}{
{"ORBIT_RETENTION_IDEMPOTENCY_DAYS", &p.IdempotencyDays},
{"ORBIT_RETENTION_TASK_DAYS", &p.TaskHistoryDays},
{"ORBIT_RETENTION_WORKFLOW_DAYS", &p.WorkflowHistoryDays},
{"ORBIT_RETENTION_EVENT_DAYS", &p.EventLogDays},
{"ORBIT_RETENTION_DELIVERY_DAYS", &p.DeliveryHistoryDays},
{"ORBIT_RETENTION_INBOX_DAYS", &p.InboxHistoryDays},
{"ORBIT_RETENTION_AUDIT_DAYS", &p.AuditLogDays},
} {
if raw := get(f.name); raw != "" {
n, err := strconv.ParseUint(raw, 10, 32)
if err != nil || n < 1 || n > 3650 {
return Policy{}, fmt.Errorf("%s must be 1-3650 days", f.name)
}
*f.dst = uint32(n)
}
}
if p.IdempotencyDays < 90 {
return Policy{}, errors.New("ORBIT_RETENTION_IDEMPOTENCY_DAYS cannot be less than 90")
}
return p, nil
}

View File

@@ -0,0 +1,65 @@
package retention
import (
"testing"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func TestDefaultsAndValidation(t *testing.T) {
ns, err := core.NewID()
if err != nil {
t.Fatal(err)
}
p := Defaults(ns)
if p.Enabled || p.Source != "default" || p.IdempotencyDays != 90 || p.TaskHistoryDays != 365 || p.AuditLogDays != 2555 {
t.Fatalf("unexpected defaults: %+v", p)
}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
p.IdempotencyDays = 89
if err := p.Validate(); err == nil {
t.Fatal("accepted an idempotency window shorter than the safety minimum")
}
}
func TestUpdateInputRequiresReasonAndKeepsSafetyMinimum(t *testing.T) {
ns, _ := core.NewID()
in := UpdateInput{
IdempotencyDays: 90, TaskHistoryDays: 30, WorkflowHistoryDays: 30,
EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30,
AuditLogDays: 365, Reason: "quarterly retention review",
}
if _, err := in.ToPolicy(ns); err != nil {
t.Fatal(err)
}
in.Reason = ""
if _, err := in.ToPolicy(ns); err == nil {
t.Fatal("accepted an empty audit reason")
}
in.Reason = "shorten"
in.IdempotencyDays = 89
if _, err := in.ToPolicy(ns); err == nil {
t.Fatal("accepted an unsafe idempotency window")
}
}
func TestFromEnvOverridesOnlyDeploymentDefaults(t *testing.T) {
values := map[string]string{
"ORBIT_RETENTION_IDEMPOTENCY_DAYS": "120",
"ORBIT_RETENTION_TASK_DAYS": "180",
"ORBIT_RETENTION_AUDIT_DAYS": "730",
}
p, err := FromEnv(func(k string) string { return values[k] })
if err != nil {
t.Fatal(err)
}
if p.IdempotencyDays != 120 || p.TaskHistoryDays != 180 || p.AuditLogDays != 730 || p.EventLogDays != DefaultEventLogDays {
t.Fatalf("environment defaults not applied: %+v", p)
}
values["ORBIT_RETENTION_IDEMPOTENCY_DAYS"] = "89"
if _, err := FromEnv(func(k string) string { return values[k] }); err == nil {
t.Fatal("accepted unsafe environment idempotency window")
}
}

View File

@@ -0,0 +1,209 @@
package retention
import (
"context"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
)
// RuntimeStore 只供受信任的归档执行器使用,控制台不能直接领取或确认租约。
type RuntimeStore interface {
ScheduleArchive(context.Context, core.ID) error
ClaimArchive(context.Context, core.ID) (*ManagedJob, error)
FinishArchive(context.Context, ManagedJob, JobStatus, string) error
ExportRetentionForJob(context.Context, core.ID, core.ID, ArchiveInput) (ArchiveSnapshot, error)
}
// ArchiveRuntime 固定一个部署卷,每次最多运行一批;所有共享该卷的节点串行占用文件锁。
type ArchiveRuntime struct {
Directory string // Directory 管理员配置的绝对路径,0700权限,不可使用容器临时层。
StorageID core.ID // StorageID 卷内标记必须相同;不是共享存储的替代品。
MaxBytes int64 // MaxBytes 整个卷内归档文件总字节上限,包含未完成作业。
Store RuntimeStore // Store 持久化计划、领取和检查点。
}
// Init 仅在空私有目录建立卷标记,已有文件缺少标记时拒绝猜测身份。
func (r *ArchiveRuntime) Init() error {
if r.StorageID.Validate() != nil || !filepath.IsAbs(r.Directory) || r.MaxBytes < 1<<20 || r.Store == nil {
return errors.New("invalid archive runtime configuration")
}
root, lock, err := openJob(r.Directory, true)
for attempt := 0; errors.Is(err, ErrArchiveBusy) && attempt < 20; attempt++ {
// 已有卷身份文件不可变;其他节点正在执行时,启动节点可以只读核对,
// 不必等待整个文件批次释放卷锁。首次发布尚未结束时短暂等待。
reader, readErr := os.OpenRoot(r.Directory)
if readErr == nil {
marker, e := readJobFile(reader, ".storage-id", 64)
reader.Close()
if e == nil {
if string(marker) != string(r.StorageID) {
return errors.New("archive volume identity mismatch")
}
return nil
}
}
time.Sleep(100 * time.Millisecond)
root, lock, err = openJob(r.Directory, true)
}
if err != nil {
return err
}
defer root.Close()
defer lock.Close()
if err := discardStaging(root); err != nil {
return err
}
data, err := readJobFile(root, ".storage-id", 64)
if errors.Is(err, fs.ErrNotExist) {
entries, e := jobEntries(root)
if e != nil {
return e
}
if len(entries) != 1 || entries[0].Name() != ".lock" {
return errors.New("archive volume identity is missing")
}
return publishFile(root, ".storage-id", []byte(r.StorageID))
}
if err != nil {
return err
}
if string(data) != string(r.StorageID) {
return errors.New("archive volume identity mismatch")
}
return nil
}
// Tick 不在数据库事务中进行文件I/O;发布后确认失败时,由同一作业验证文件再收敛。
func (r *ArchiveRuntime) Tick(ctx context.Context) error {
root, lock, err := openJob(r.Directory, false)
if errors.Is(err, ErrArchiveBusy) {
return nil
}
if err != nil {
return err
}
defer root.Close()
defer lock.Close()
marker, err := readJobFile(root, ".storage-id", 64)
if err != nil || string(marker) != string(r.StorageID) {
return errors.New("archive volume identity mismatch")
}
if err = r.Store.ScheduleArchive(ctx, r.StorageID); err != nil {
return err
}
job, err := r.Store.ClaimArchive(ctx, r.StorageID)
if err != nil || job == nil {
return err
}
name := string(job.NamespaceID) + "-" + string(job.ID)
files := 0
total, current, err := archiveVolumeUsage(ctx, root, ".", name, &files)
code := ""
var status JobStatus
if err != nil {
code = "FILE_CHECK_FAILED"
if errors.Is(err, ErrArchiveLimit) {
code = "STORAGE_LIMIT"
}
} else {
budget := min(job.MaxBytes, r.MaxBytes-total+current)
if budget < 1<<20 {
code = "STORAGE_LIMIT"
} else {
checkpoint := JobStatus{JobID: job.ID, Batches: job.Batches, Roots: job.Roots, Bytes: job.Bytes, LastSHA256: job.LastSHA256}
options := JobOptions{JobID: job.ID, RequireExisting: job.Attempts > 1, Checkpoint: &checkpoint, MaxBatches: 1, MaxBytes: budget}
status, err = RunArchive(ctx, filepath.Join(r.Directory, name), JobSpec{job.NamespaceID, job.Kind, job.PolicyRevision, job.BatchSize}, options, func(ctx context.Context, id core.ID, in ArchiveInput) (ArchiveSnapshot, error) {
out, err := r.Store.ExportRetentionForJob(ctx, job.NamespaceID, id, in)
if err != nil {
code = "EXPORT_FAILED"
switch fault.Code(err) {
case "STATE_CONFLICT":
code = "POLICY_CHANGED"
case "PERMISSION_DENIED":
code = "PERMISSION_DENIED"
}
}
return out, err
})
if err != nil && code == "" {
code = "FILE_CHECK_FAILED"
if errors.Is(err, ErrArchiveLimit) {
code = "STORAGE_LIMIT"
}
if errors.Is(err, ErrArchiveBusy) {
code = "STORAGE_BUSY"
}
}
}
}
// 关闭/超时不写成文件损坏;保留租约,下一节点在到期后从同一文件链接管。
if ctx.Err() != nil {
return ctx.Err()
}
finish, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return r.Store.FinishArchive(finish, *job, status, code)
}
// archiveVolumeUsage 分块读取目录项,避免 WalkDir 在大目录中先一次载入全部文件名。
// 平台卷只有根目录和单层作业目录,意外嵌套或链接一律拒绝。
func archiveVolumeUsage(ctx context.Context, root *os.Root, directory, currentJob string, count *int) (total, current int64, err error) {
dir, err := root.Open(directory)
if err != nil {
return 0, 0, err
}
defer dir.Close()
for {
if err := ctx.Err(); err != nil {
return 0, 0, err
}
entries, readErr := dir.ReadDir(256)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return 0, 0, readErr
}
for _, entry := range entries {
*count++
if *count > 100000 {
return 0, 0, ErrArchiveLimit
}
if entry.Type()&fs.ModeSymlink != 0 {
return 0, 0, errors.New("archive volume contains a symlink")
}
if entry.IsDir() {
if directory != "." {
return 0, 0, errors.New("archive volume contains nested directories")
}
n, j, err := archiveVolumeUsage(ctx, root, entry.Name(), currentJob, count)
if err != nil {
return 0, 0, err
}
total += n
current += j
continue
}
info, err := entry.Info()
if err != nil {
return 0, 0, err
}
if !info.Mode().IsRegular() {
return 0, 0, errors.New("archive volume contains a special file")
}
total += info.Size()
if directory == currentJob {
current += info.Size()
}
}
if errors.Is(readErr, io.EOF) {
return total, current, nil
}
}
}

View File

@@ -0,0 +1,96 @@
package retention
import (
"context"
"os"
"path/filepath"
"testing"
core "git.yuntuo.hk/k2safe/orbit/model"
)
type idleArchiveStore struct {
RuntimeStore
scheduled int
}
func (s *idleArchiveStore) ScheduleArchive(context.Context, core.ID) error { s.scheduled++; return nil }
func (s *idleArchiveStore) ClaimArchive(context.Context, core.ID) (*ManagedJob, error) {
return nil, nil
}
func TestArchiveVolumeIdentityLockAndInitializationRecovery(t *testing.T) {
store := &idleArchiveStore{}
id, _ := core.NewID()
runtime := &ArchiveRuntime{Directory: filepath.Join(t.TempDir(), "volume"), StorageID: id, MaxBytes: 8 << 20, Store: store}
if err := runtime.Init(); err != nil {
t.Fatal(err)
}
root, lock, err := openJob(runtime.Directory, false)
if err != nil {
t.Fatal(err)
}
// 其他节点正在执行时,新节点仍可读取不可变卷身份并正常启动。
if err = runtime.Init(); err != nil {
t.Fatal("another node could not initialize", err)
}
other := *runtime
other.StorageID, _ = core.NewID()
if err = other.Init(); err == nil {
t.Fatal("wrong volume accepted while busy")
}
if err = runtime.Tick(context.Background()); err != nil || store.scheduled != 0 {
t.Fatal("busy volume scheduled work", err)
}
lock.Close()
root.Close()
if err = runtime.Tick(context.Background()); err != nil || store.scheduled != 1 {
t.Fatal("unlocked volume did not schedule", err)
}
if err = other.Init(); err == nil {
t.Fatal("wrong volume accepted")
}
// 首次身份文件原子发布前崩溃,只允许丢弃本工具的安全临时文件。
fresh := *runtime
fresh.Directory = filepath.Join(t.TempDir(), "interrupted")
if err = os.Mkdir(fresh.Directory, 0700); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(fresh.Directory, ".write.tmp"), []byte(id), 0600); err != nil {
t.Fatal(err)
}
if err = fresh.Init(); err != nil {
t.Fatal("volume init did not recover", err)
}
}
func TestArchiveVolumeScanCountsAllJobsAndRejectsLinks(t *testing.T) {
dir := t.TempDir()
job := "job"
if err := os.Mkdir(filepath.Join(dir, job), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "other"), []byte("volume"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, job, "batch"), []byte("job"), 0600); err != nil {
t.Fatal(err)
}
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
count := 0
total, current, err := archiveVolumeUsage(context.Background(), root, ".", job, &count)
if err != nil || total != 9 || current != 3 || count != 3 {
t.Fatal(total, current, count, err)
}
if err = os.Symlink("other", filepath.Join(dir, "unexpected")); err != nil {
t.Fatal(err)
}
count = 0
if _, _, err = archiveVolumeUsage(context.Background(), root, ".", job, &count); err == nil {
t.Fatal("volume symlink accepted")
}
}

View File

@@ -0,0 +1,101 @@
package retention
import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// ArchiveKinds 是周期归档支持的固定类别,不接受任意表名或 SQL。
var ArchiveKinds = [...]string{"tasks", "workflows", "events", "audit", "deliveries", "inbox", "idempotency"}
// ArchivePlan 是命名空间内单一类别的周期计划;失败作业阻止本类别再生成新作业。
type ArchivePlan struct {
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 由登录会话确定。
Kind string `json:"kind"` // Kind 同一命名空间内唯一的归档类别。
Revision uint64 `json:"revision,string"` // Revision 配置版本;尚未保存时为0,后台触发不改变配置版本。
Enabled bool `json:"enabled"` // Enabled 仅控制新作业触发,关闭不会中断已有作业。
PolicyRevision uint64 `json:"policy_revision,string"` // PolicyRevision 管理员确认的保留策略版本,变更策略后须重新保存计划。
StorageID core.ID `json:"storage_id"` // StorageID 固定归档卷身份,由部署配置提供,不接受浏览器任意路径。
IntervalSeconds uint32 `json:"interval_seconds"` // IntervalSeconds 周期秒数,3600至2592000;错过多次只合并触发一次。
BatchSize int `json:"batch_size"` // BatchSize 单批根记录数,1至100。
MaxBytes int64 `json:"max_bytes"` // MaxBytes 每个作业的文件字节上限,1 MiB至1 GiB。
NextRunAt *time.Time `json:"next_run_at"` // NextRunAt 下次触发UTC时间,未保存时为空;首次启用立即到期。
ActiveJobID *core.ID `json:"active_job_id"` // ActiveJobID 未完成或待处理失败作业;成功或人工关闭后清空。
UpdatedAt *time.Time `json:"updated_at"` // UpdatedAt 最后配置修改UTC时间,未保存为空。
}
// PlanInput 完整替换计划配置;保存与管理审计、幂等回执同事务。
type PlanInput struct {
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"` // ExpectedRevision 已读取的计划版本。
PolicyRevision uint64 `json:"policy_revision,string" required:"true"` // PolicyRevision 已确认的保留策略版本。
Enabled bool `json:"enabled" required:"true"` // Enabled 是否开始周期触发。
IntervalSeconds uint32 `json:"interval_seconds" required:"true"` // IntervalSeconds 周期秒数。
BatchSize int `json:"batch_size" required:"true"` // BatchSize 每批根记录数。
MaxBytes int64 `json:"max_bytes" required:"true"` // MaxBytes 单作业总文件预算,字节。
Reason string `json:"reason" required:"true"` // Reason 单行操作原因,最多1024个UTF-8字节。
}
// ManagedJob 保存周期作业权威状态;文件内容和游标不通过管理列表返回。
type ManagedJob struct {
ID core.ID `json:"id"` // ID 与目录名及文件清单作业ID一致。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 所属命名空间。
Kind string `json:"kind"` // Kind 固定归档类别。
PlanRevision uint64 `json:"plan_revision,string"` // PlanRevision 创建时固定的计划配置版本。
PolicyRevision uint64 `json:"policy_revision,string"` // PolicyRevision 作业期间不可替换的保留策略版本。
StorageID core.ID `json:"storage_id"` // StorageID 固定归档卷身份,接管节点必须挂载同一物理卷。
BatchSize int `json:"batch_size"` // BatchSize 创建时固定的单批根记录上限。
MaxBytes int64 `json:"max_bytes"` // MaxBytes 累计字节预算,仅失败重试时允许提高。
State string `json:"state"` // State 为PENDING/RUNNING/SUCCEEDED/FAILED/ABANDONED。
Revision uint64 `json:"revision,string"` // Revision 每次领取、确认、人工操作递增,用于防止迟到覆盖。
Attempts uint32 `json:"attempts"` // Attempts 累计批次领取次数,包含崩溃接管,不等于业务任务重试次数。
Batches int `json:"batches"` // Batches 已验证并入库的连续批次数。
Roots int64 `json:"roots"` // Roots 已验证根记录总数。
Bytes int64 `json:"bytes"` // Bytes 已验证清单和批次总字节数。
LastSHA256 string `json:"last_sha256"` // LastSHA256 数据库确认的文件链尾摘要;恢复必须验证此前缀。
ErrorCode string `json:"error_code"` // ErrorCode 固定错误分类,不保存原始路径、业务正文或数据库错误。
LeaseID core.ID `json:"-"` // LeaseID 当前领取随机代次,不向浏览器返回。
LeaseUntil *time.Time `json:"lease_until"` // LeaseUntil UTC租约截止,非执行中为空;过期可接管。
CreatedAt time.Time `json:"created_at"` // CreatedAt 作业受理UTC时间。
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近状态变更UTC时间。
}
// JobActionInput 是失败作业的人工恢复或关闭输入,必须留有原因。
type JobActionInput struct {
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"` // ExpectedRevision 已读取的作业版本。
Action string `json:"action" required:"true"` // Action 仅retry或abandon;后者保留已有文件与审计。
MaxBytes int64 `json:"max_bytes" required:"true"` // MaxBytes 重试可提高预算,关闭时保持原值。
Reason string `json:"reason" required:"true"` // Reason 人工操作原因。
}
// JobPage 采用已返回作业ID作为翻页位置;位置必须属于当前命名空间。
type JobPage struct {
Items []ManagedJob `json:"items"` // Items 本页最多20个作业。
NextBefore string `json:"next_before"` // NextBefore 下一页位置;空表示当前已到末页。
}
// SchedulerStore 将控制台管理与后台执行的持久事务统一到存储层。
type SchedulerStore interface {
ListArchivePlans(context.Context, auth.Token) ([]ArchivePlan, error)
SaveArchivePlan(context.Context, auth.Token, string, string, core.ID, PlanInput) (ArchivePlan, error)
ListArchiveJobs(context.Context, auth.Token, string) (JobPage, error)
ActArchiveJob(context.Context, auth.Token, core.ID, string, JobActionInput) (ManagedJob, error)
}
func ValidReason(s string) bool {
return strings.TrimSpace(s) != "" && utf8.ValidString(s) && len(s) <= 1024 && !strings.ContainsAny(s, "\r\n")
}
func (in PlanInput) Validate(namespace core.ID, kind string) error {
if err := (JobSpec{namespace, kind, in.PolicyRevision, in.BatchSize}).Validate(); err != nil {
return err
}
if in.IntervalSeconds < 3600 || in.IntervalSeconds > 2592000 || in.MaxBytes < 1<<20 || in.MaxBytes > 1<<30 || !ValidReason(in.Reason) {
return errors.New("invalid archive interval, byte budget or reason")
}
return nil
}

View File

@@ -92,6 +92,9 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_concurrency_key, DROP COLUMN concurrency_key, DROP COLUMN concurrency_key_digest`)
f.exec(`ALTER TABLE pulse_attempts DROP COLUMN concurrency_token`)
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
// 已经存在的 v29/v30 表误当成迁移重复执行。
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)

View File

@@ -0,0 +1,441 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
const archivePlanColumns = `namespace_id,kind,revision,enabled,policy_revision,storage_id,interval_seconds,batch_size,max_bytes,next_run_at,active_job_id,updated_at`
const archiveJobColumns = `namespace_id,id,kind,plan_revision,policy_revision,storage_id,batch_size,max_bytes,state,revision,attempts,batches,roots,bytes,last_sha256,error_code,lease_id,lease_until,created_at,updated_at`
type archiveScanner interface{ Scan(...any) error }
func scanArchivePlan(row archiveScanner) (p retention.ArchivePlan, err error) {
err = row.Scan(&p.NamespaceID, &p.Kind, &p.Revision, &p.Enabled, &p.PolicyRevision, &p.StorageID, &p.IntervalSeconds, &p.BatchSize, &p.MaxBytes, &p.NextRunAt, &p.ActiveJobID, &p.UpdatedAt)
return
}
func scanArchiveJob(row archiveScanner) (j retention.ManagedJob, err error) {
var lease *core.ID
err = row.Scan(&j.NamespaceID, &j.ID, &j.Kind, &j.PlanRevision, &j.PolicyRevision, &j.StorageID, &j.BatchSize, &j.MaxBytes, &j.State, &j.Revision, &j.Attempts, &j.Batches, &j.Roots, &j.Bytes, &j.LastSHA256, &j.ErrorCode, &lease, &j.LeaseUntil, &j.CreatedAt, &j.UpdatedAt)
if lease != nil {
j.LeaseID = *lease
}
return
}
func readArchivePlan(ctx context.Context, tx *sql.Tx, ns core.ID, kind string) (retention.ArchivePlan, error) {
p, err := scanArchivePlan(tx.QueryRowContext(ctx, `SELECT `+archivePlanColumns+` FROM orbit_archive_plans WHERE namespace_id=? AND kind=? FOR UPDATE`, ns, kind))
if errors.Is(err, sql.ErrNoRows) {
return retention.ArchivePlan{NamespaceID: ns, Kind: kind, IntervalSeconds: 86400, BatchSize: 20, MaxBytes: 256 << 20}, nil
}
return p, err
}
func readArchiveJob(ctx context.Context, tx *sql.Tx, ns, id core.ID) (retention.ManagedJob, error) {
j, err := scanArchiveJob(tx.QueryRowContext(ctx, `SELECT `+archiveJobColumns+` FROM orbit_archive_jobs WHERE namespace_id=? AND id=? FOR UPDATE`, ns, id))
if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound
}
return j, err
}
func archiveAudit(ctx context.Context, tx *sql.Tx, ns, resource, op core.ID, actor, action, kind, reason string, before, after any) error {
id, err := core.NewID()
if err != nil {
return err
}
b, err := json.Marshal(before)
if err != nil {
return err
}
a, err := json.Marshal(after)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,?,?)", ns, id, op, actor, action, kind, resource, reason, string(b), string(a))
return err
}
func archiveKey(key string) error {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return fault.Invalid("idempotency key must be 1-128 bytes")
}
return nil
}
func (s *RetentionPolicyStore) ListArchivePlans(ctx context.Context, t auth.Token) (out []retention.ArchivePlan, err error) {
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := retentionActor(ctx, tx, t); err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `SELECT `+archivePlanColumns+` FROM orbit_archive_plans WHERE namespace_id=? ORDER BY kind`, t.NamespaceID)
if err != nil {
return err
}
defer rows.Close()
found := map[string]retention.ArchivePlan{}
for rows.Next() {
p, err := scanArchivePlan(rows)
if err != nil {
return err
}
found[p.Kind] = p
}
if err := rows.Err(); err != nil {
return err
}
out = []retention.ArchivePlan{}
for _, kind := range retention.ArchiveKinds {
p, ok := found[kind]
if !ok {
p = retention.ArchivePlan{NamespaceID: t.NamespaceID, Kind: kind, IntervalSeconds: 86400, BatchSize: 20, MaxBytes: 256 << 20}
}
out = append(out, p)
}
return nil
})
return
}
func (s *RetentionPolicyStore) SaveArchivePlan(ctx context.Context, t auth.Token, key, kind string, storage core.ID, in retention.PlanInput) (out retention.ArchivePlan, err error) {
if err = in.Validate(t.NamespaceID, kind); err != nil {
return out, fault.Invalid(err.Error())
}
if err = archiveKey(key); err != nil {
return
}
// 卷身份属于幂等请求摘要;跨不同卷的节点不能把同一保存解释成不同目标。
input := struct {
Kind string
Storage core.ID
Input retention.PlanInput
}{kind, storage, in}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
user, err := retentionActor(ctx, tx, t)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "retention.plan.update", kind, key, "archive_plan", t.NamespaceID, input)
if err != nil {
return err
}
if cached != nil {
return json.Unmarshal(cached, &out)
}
policy, err := s.readPolicy(ctx, tx, t.NamespaceID, true)
if err != nil {
return err
}
if policy.Revision != in.PolicyRevision || (in.Enabled && !policy.Enabled) {
return fault.Conflict
}
before, err := readArchivePlan(ctx, tx, t.NamespaceID, kind)
if err != nil {
return err
}
if before.Revision != in.ExpectedRevision {
return fault.Conflict
}
target := storage
if target == "" && !in.Enabled {
target = before.StorageID
}
if target.Validate() != nil {
return fault.Invalid("archive storage is not configured")
}
if before.ActiveJobID != nil && before.StorageID != target {
return fault.Conflict
}
if before.Revision == 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_archive_plans(namespace_id,kind,enabled,policy_revision,storage_id,interval_seconds,batch_size,max_bytes,next_run_at) VALUES(?,?,?,?,?,?,?,?,UTC_TIMESTAMP(6))`, t.NamespaceID, kind, in.Enabled, in.PolicyRevision, target, in.IntervalSeconds, in.BatchSize, in.MaxBytes)
} else {
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET revision=revision+1,next_run_at=IF(enabled=FALSE AND ?=TRUE,UTC_TIMESTAMP(6),next_run_at),enabled=?,policy_revision=?,storage_id=?,interval_seconds=?,batch_size=?,max_bytes=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND kind=?`, in.Enabled, in.Enabled, in.PolicyRevision, target, in.IntervalSeconds, in.BatchSize, in.MaxBytes, t.NamespaceID, kind)
}
if err != nil {
return err
}
out, err = readArchivePlan(ctx, tx, t.NamespaceID, kind)
if err != nil {
return err
}
if err = archiveAudit(ctx, tx, t.NamespaceID, t.NamespaceID, identity, "console:"+string(user), "retention.plan.update", "archive_plan", in.Reason, before, out); err != nil {
return err
}
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
})
return
}
func (s *RetentionPolicyStore) ListArchiveJobs(ctx context.Context, t auth.Token, before string) (out retention.JobPage, err error) {
if before != "" && core.ID(before).Validate() != nil {
return out, fault.Invalid("invalid archive page position")
}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := retentionActor(ctx, tx, t); err != nil {
return err
}
q := `SELECT ` + archiveJobColumns + ` FROM orbit_archive_jobs WHERE namespace_id=?`
args := []any{t.NamespaceID}
if before != "" {
var at time.Time
if err := tx.QueryRowContext(ctx, `SELECT created_at FROM orbit_archive_jobs WHERE namespace_id=? AND id=?`, t.NamespaceID, core.ID(before)).Scan(&at); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
}
return err
}
q += ` AND (created_at<? OR (created_at=? AND id<?))`
args = append(args, at, at, core.ID(before))
}
rows, err := tx.QueryContext(ctx, q+` ORDER BY created_at DESC,id DESC LIMIT 21`, args...)
if err != nil {
return err
}
defer rows.Close()
out = retention.JobPage{Items: []retention.ManagedJob{}}
for rows.Next() {
j, err := scanArchiveJob(rows)
if err != nil {
return err
}
out.Items = append(out.Items, j)
}
if err := rows.Err(); err != nil {
return err
}
if len(out.Items) > 20 {
out.Items = out.Items[:20]
out.NextBefore = string(out.Items[19].ID)
}
return nil
})
return
}
func (s *RetentionPolicyStore) ActArchiveJob(ctx context.Context, t auth.Token, id core.ID, key string, in retention.JobActionInput) (out retention.ManagedJob, err error) {
if id.Validate() != nil || (in.Action != "retry" && in.Action != "abandon") || !retention.ValidReason(in.Reason) || in.MaxBytes < 1<<20 || in.MaxBytes > 1<<30 {
return out, fault.Invalid("invalid archive action, budget or reason")
}
if err = archiveKey(key); err != nil {
return
}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
user, err := retentionActor(ctx, tx, t)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "retention.job.action", string(id), key, "archive_job", id, in)
if err != nil {
return err
}
if cached != nil {
return json.Unmarshal(cached, &out)
}
var kind string
if err := tx.QueryRowContext(ctx, `SELECT kind FROM orbit_archive_jobs WHERE namespace_id=? AND id=?`, t.NamespaceID, id).Scan(&kind); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
}
return err
}
p, err := readArchivePlan(ctx, tx, t.NamespaceID, kind)
if err != nil {
return err
}
j, err := readArchiveJob(ctx, tx, t.NamespaceID, id)
if err != nil {
return err
}
if p.ActiveJobID == nil || *p.ActiveJobID != id || j.State != "FAILED" || j.Revision != in.ExpectedRevision || in.MaxBytes < j.MaxBytes {
return fault.Conflict
}
next := "PENDING"
if in.Action == "abandon" {
next = "ABANDONED"
if in.MaxBytes != j.MaxBytes {
return fault.Conflict
}
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_jobs SET state=?,revision=revision+1,max_bytes=?,error_code='',updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, next, in.MaxBytes, t.NamespaceID, id)
if err != nil {
return err
}
if next == "ABANDONED" {
if _, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET active_job_id=NULL WHERE namespace_id=? AND kind=? AND active_job_id=?`, t.NamespaceID, kind, id); err != nil {
return err
}
}
out, err = readArchiveJob(ctx, tx, t.NamespaceID, id)
if err != nil {
return err
}
if err = archiveAudit(ctx, tx, t.NamespaceID, id, identity, "console:"+string(user), "retention.job."+in.Action, "archive_job", in.Reason, j, out); err != nil {
return err
}
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
})
return
}
// ScheduleArchive 在数据库时钟下合并错过的时间槽;策略、计划和新作业同事务核验。
func (s *RetentionPolicyStore) ScheduleArchive(ctx context.Context, storage core.ID) error {
if storage.Validate() != nil {
return fault.Invalid("invalid storage id")
}
rows, err := s.db.QueryContext(ctx, `SELECT p.namespace_id,p.kind FROM orbit_archive_plans p JOIN orbit_retention_policies r ON r.namespace_id=p.namespace_id AND r.revision=p.policy_revision AND r.enabled=TRUE JOIN orbit_namespaces n ON n.id=p.namespace_id AND n.enabled=TRUE JOIN orbit_applications a ON a.id=n.application_id AND a.enabled=TRUE WHERE p.storage_id=? AND p.enabled=TRUE AND p.active_job_id IS NULL AND p.next_run_at<=UTC_TIMESTAMP(6) ORDER BY p.next_run_at LIMIT 16`, storage)
if err != nil {
return err
}
type candidate struct {
ns core.ID
kind string
}
var candidates []candidate
for rows.Next() {
var c candidate
if err = rows.Scan(&c.ns, &c.kind); err != nil {
rows.Close()
return err
}
candidates = append(candidates, c)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for _, c := range candidates {
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
var enabled bool
if err := tx.QueryRowContext(ctx, `SELECT a.enabled AND n.enabled FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? FOR SHARE`, c.ns).Scan(&enabled); err != nil {
return err
}
if !enabled {
return nil
}
policy, err := s.readPolicy(ctx, tx, c.ns, true)
if err != nil {
return err
}
p, err := readArchivePlan(ctx, tx, c.ns, c.kind)
if err != nil {
return err
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
if !p.Enabled || p.ActiveJobID != nil || p.StorageID != storage || p.NextRunAt == nil || p.NextRunAt.After(now) || !policy.Enabled || policy.Revision != p.PolicyRevision {
return nil
}
id, err := core.NewID()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_archive_jobs(namespace_id,id,kind,plan_revision,policy_revision,storage_id,batch_size,max_bytes) VALUES(?,?,?,?,?,?,?,?)`, c.ns, id, c.kind, p.Revision, p.PolicyRevision, storage, p.BatchSize, p.MaxBytes)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET active_job_id=?,next_run_at=? WHERE namespace_id=? AND kind=?`, id, now.Add(time.Duration(p.IntervalSeconds)*time.Second), c.ns, c.kind)
if err != nil {
return err
}
j, err := readArchiveJob(ctx, tx, c.ns, id)
if err != nil {
return err
}
return archiveAudit(ctx, tx, c.ns, id, id, "archive:scheduler", "retention.job.created", "archive_job", "周期归档到期", nil, j)
})
if err != nil {
return err
}
}
return nil
}
// ClaimArchive 领取一批工作;五分钟租约超时后重领,以随机代次隔离迟到确认。
func (s *RetentionPolicyStore) ClaimArchive(ctx context.Context, storage core.ID) (job *retention.ManagedJob, err error) {
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
job = nil
j, err := scanArchiveJob(tx.QueryRowContext(ctx, `SELECT `+archiveJobColumns+` FROM orbit_archive_jobs WHERE storage_id=? AND (state='PENDING' OR (state='RUNNING' AND lease_until<=UTC_TIMESTAMP(6))) ORDER BY updated_at,id LIMIT 1 FOR UPDATE SKIP LOCKED`, storage))
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
lease, err := core.NewID()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_jobs SET state='RUNNING',revision=revision+1,attempts=attempts+1,lease_id=?,lease_until=TIMESTAMPADD(SECOND,300,UTC_TIMESTAMP(6)),updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, lease, j.NamespaceID, j.ID)
if err != nil {
return err
}
claimed, err := readArchiveJob(ctx, tx, j.NamespaceID, j.ID)
if err != nil {
return err
}
job = &claimed
return nil
})
return
}
// FinishArchive 仅确认当前未过期领取;失败不释放计划占位,避免不断生成重复失败作业。
func (s *RetentionPolicyStore) FinishArchive(ctx context.Context, claim retention.ManagedJob, status retention.JobStatus, code string) error {
switch code {
case "", "FILE_CHECK_FAILED", "STORAGE_LIMIT", "STORAGE_BUSY", "POLICY_CHANGED", "PERMISSION_DENIED", "EXPORT_FAILED", "INTERRUPTED":
default:
return fault.Invalid("invalid archive result code")
}
return WithTx(ctx, s.db, func(tx *sql.Tx) error {
p, err := readArchivePlan(ctx, tx, claim.NamespaceID, claim.Kind)
if err != nil {
return err
}
j, err := readArchiveJob(ctx, tx, claim.NamespaceID, claim.ID)
if err != nil {
return err
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
if p.ActiveJobID == nil || *p.ActiveJobID != j.ID || j.State != "RUNNING" || j.LeaseID != claim.LeaseID || j.Revision != claim.Revision || j.LeaseUntil == nil || !j.LeaseUntil.After(now) {
return fault.Conflict
}
next := "PENDING"
if code != "" {
next = "FAILED"
} else if status.Completed {
next = "SUCCEEDED"
}
if code == "" && (status.JobID != j.ID || status.Batches < j.Batches || status.Roots < j.Roots || status.Bytes < j.Bytes || len(status.LastSHA256) != 64) {
return fault.Conflict
}
// 校验失败的部分文件不能覆盖最后一次数据库确认的可信检查点。
if code != "" {
status = retention.JobStatus{JobID: j.ID, Batches: j.Batches, Roots: j.Roots, Bytes: j.Bytes, LastSHA256: j.LastSHA256}
}
_, err = tx.ExecContext(ctx, `UPDATE orbit_archive_jobs SET state=?,revision=revision+1,batches=?,roots=?,bytes=?,last_sha256=?,error_code=?,lease_id=NULL,lease_until=NULL,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, next, status.Batches, status.Roots, status.Bytes, status.LastSHA256, code, j.NamespaceID, j.ID)
if err != nil {
return err
}
if next == "SUCCEEDED" {
if _, err = tx.ExecContext(ctx, `UPDATE orbit_archive_plans SET active_job_id=NULL WHERE namespace_id=? AND kind=? AND active_job_id=?`, j.NamespaceID, j.Kind, j.ID); err != nil {
return err
}
}
if next == "PENDING" {
return nil
}
out, err := readArchiveJob(ctx, tx, j.NamespaceID, j.ID)
if err != nil {
return err
}
return archiveAudit(ctx, tx, j.NamespaceID, j.ID, j.LeaseID, "archive:"+string(j.ID), "retention.job."+strings.ToLower(next), "archive_job", "归档执行结果", j, out)
})
}

View File

@@ -0,0 +1,380 @@
package mysql
import (
"context"
"database/sql"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
func archiveFixture(t *testing.T, payloadBytes ...int) (context.Context, *sql.DB, *RetentionPolicyStore, auth.Token, core.ID) {
t.Helper()
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
t.Cleanup(cancel)
db, err := Open(ctx, dsn, 12, 4)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
app := testID(t)
username := "archive-" + string(app)
var boot BootstrapResult
if err = Bootstrap(ctx, db, BootstrapInput{Application: string(app), Namespace: "archive", Environment: "test", Queue: "archive", ConsoleUsername: username, ConsolePassword: "test-only-strong-password-123"}, func(b BootstrapResult) error { boot = b; return nil }); err != nil {
t.Fatal(err)
}
login, err := NewConsoleAuthStore(db).Login(ctx, username, "test-only-strong-password-123", "archive-test", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
token, err := auth.Parse(login.Token)
if err != nil {
t.Fatal(err)
}
store := NewRetentionPolicyStore(db)
_, err = store.UpdateRetentionPolicy(ctx, token, "initial", retention.UpdateInput{Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 1, WorkflowHistoryDays: 1, EventLogDays: 1, DeliveryHistoryDays: 1, InboxHistoryDays: 1, AuditLogDays: 1, Reason: "isolated archive test"})
if err != nil {
t.Fatal(err)
}
// 三条过期审计作为真实归档数据,分两批读取,避免凭空伪造任务生命周期。
size := 0
if len(payloadBytes) > 0 {
size = payloadBytes[0]
}
for i := 0; i < 3; i++ {
_, err = db.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,created_at) VALUES(?,?,?,'test','fixture','archive_job',?,'source retained',JSON_OBJECT('fixture',REPEAT('x',?)),TIMESTAMPADD(DAY,-3,UTC_TIMESTAMP(6)))", boot.NamespaceID, testID(t), testID(t), testID(t), size)
if err != nil {
t.Fatal(err)
}
}
return ctx, db, store, token, testID(t)
}
func archivePlanInput() retention.PlanInput {
return retention.PlanInput{PolicyRevision: 1, Enabled: true, IntervalSeconds: 3600, BatchSize: 2, MaxBytes: 4 << 20, Reason: "enable isolated schedule"}
}
func mustArchiveJobs(t *testing.T, ctx context.Context, s *RetentionPolicyStore, token auth.Token) []retention.ManagedJob {
t.Helper()
p, err := s.ListArchiveJobs(ctx, token, "")
if err != nil {
t.Fatal(err)
}
return p.Items
}
func TestIntegrationArchiveSchedulerRaceResumeAndFencing(t *testing.T) {
ctx, db, s, token, volume := archiveFixture(t)
in := archivePlanInput()
p, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in)
if err != nil {
t.Fatal(err)
}
replay, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in)
if err != nil || replay.Revision != p.Revision {
t.Fatal("plan replay", err)
}
if _, err = s.SaveArchivePlan(ctx, token, "stale", "audit", volume, in); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("stale plan accepted", err)
}
var wg sync.WaitGroup
errs := make(chan error, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); errs <- NewRetentionPolicyStore(db).ScheduleArchive(ctx, volume) }()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
jobs := mustArchiveJobs(t, ctx, s, token)
if len(jobs) != 1 || jobs[0].PlanRevision != p.Revision {
t.Fatalf("duplicate schedules: %+v", jobs)
}
claims := make(chan *retention.ManagedJob, 8)
errs = make(chan error, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); j, e := s.ClaimArchive(ctx, volume); claims <- j; errs <- e }()
}
wg.Wait()
close(claims)
close(errs)
for e := range errs {
if e != nil {
t.Fatal(e)
}
}
var old *retention.ManagedJob
for j := range claims {
if j != nil {
if old != nil {
t.Fatal("duplicate claim")
}
old = j
}
}
if old == nil {
t.Fatal("no claim")
}
root := filepath.Join(t.TempDir(), "volume")
runtime := &retention.ArchiveRuntime{Directory: root, StorageID: volume, MaxBytes: 16 << 20, Store: s}
if err = runtime.Init(); err != nil {
t.Fatal(err)
}
dir := filepath.Join(root, string(token.NamespaceID)+"-"+string(old.ID))
spec := retention.JobSpec{NamespaceID: token.NamespaceID, Kind: "audit", PolicyRevision: 1, BatchSize: 2}
first, err := retention.RunArchive(ctx, dir, spec, retention.JobOptions{JobID: old.ID, MaxBatches: 1, MaxBytes: 4 << 20}, func(ctx context.Context, id core.ID, in retention.ArchiveInput) (retention.ArchiveSnapshot, error) {
return s.ExportRetentionForJob(ctx, token.NamespaceID, id, in)
})
if err != nil || first.Batches != 1 || first.Roots != 2 || first.Completed {
t.Fatal("first batch", first, err)
}
// 模拟文件已落盘但数据库确认尚未提交;租约到期后另一节点恢复同一作业。
if _, err = db.ExecContext(ctx, `UPDATE orbit_archive_jobs SET lease_until=TIMESTAMPADD(SECOND,-1,UTC_TIMESTAMP(6)) WHERE namespace_id=? AND id=?`, token.NamespaceID, old.ID); err != nil {
t.Fatal(err)
}
newer, err := s.ClaimArchive(ctx, volume)
if err != nil || newer == nil || newer.LeaseID == old.LeaseID {
t.Fatal("reclaim", err)
}
if err = s.FinishArchive(ctx, *old, first, ""); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("stale claimant advanced", err)
}
if err = s.FinishArchive(ctx, *newer, first, ""); err != nil {
t.Fatal(err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
jobs = mustArchiveJobs(t, ctx, s, token)
if len(jobs) != 1 || jobs[0].ID != old.ID || jobs[0].State != "SUCCEEDED" || jobs[0].Roots != 3 || jobs[0].Batches != 2 {
t.Fatalf("resume result: %+v", jobs)
}
verified, err := retention.VerifyArchive(ctx, dir)
if err != nil || verified.LastSHA256 != jobs[0].LastSHA256 || !verified.Completed {
t.Fatal("offline mismatch", err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
if len(mustArchiveJobs(t, ctx, s, token)) != 1 {
t.Fatal("replayed due slot")
}
var sources, fks int
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='fixture'`, token.NamespaceID).Scan(&sources); err != nil || sources != 3 {
t.Fatal("source deleted", err)
}
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fks); err != nil || fks != 0 {
t.Fatal("foreign keys introduced", err)
}
t.Log("8 schedulers -> 1 job; 8 claimants -> 1 lease; durable file prefix survives missing DB confirmation; old lease rejected; 3 source rows retained")
}
func TestIntegrationArchiveFailureRetryPolicyAndScope(t *testing.T) {
ctx, db, s, token, volume := archiveFixture(t)
in := archivePlanInput()
if _, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in); err != nil {
t.Fatal(err)
}
runtime := &retention.ArchiveRuntime{Directory: filepath.Join(t.TempDir(), "volume"), StorageID: volume, MaxBytes: 16 << 20, Store: s}
if err := runtime.Init(); err != nil {
t.Fatal(err)
}
if err := runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
j := mustArchiveJobs(t, ctx, s, token)[0]
if j.State != "PENDING" || j.Batches != 1 {
t.Fatalf("first chunk %+v", j)
}
dir := filepath.Join(runtime.Directory, string(token.NamespaceID)+"-"+string(j.ID))
file := filepath.Join(dir, "batch-000001.json")
saved, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(file, []byte("broken"), 0600); err != nil {
t.Fatal(err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
failed := mustArchiveJobs(t, ctx, s, token)[0]
if failed.State != "FAILED" || failed.ErrorCode != "FILE_CHECK_FAILED" || failed.LastSHA256 != j.LastSHA256 || failed.Batches != 1 {
t.Fatalf("failed checkpoint %+v", failed)
}
// 失败占位阻止到期计划另建作业。
if _, err = db.ExecContext(ctx, `UPDATE orbit_archive_plans SET next_run_at=TIMESTAMPADD(DAY,-2,UTC_TIMESTAMP(6)) WHERE namespace_id=?`, token.NamespaceID); err != nil {
t.Fatal(err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
if len(mustArchiveJobs(t, ctx, s, token)) != 1 {
t.Fatal("failure spawned another job")
}
action := retention.JobActionInput{ExpectedRevision: failed.Revision, Action: "retry", MaxBytes: failed.MaxBytes, Reason: "restored exact original archive file"}
if err = os.WriteFile(file, saved, 0600); err != nil {
t.Fatal(err)
}
retried, err := s.ActArchiveJob(ctx, token, j.ID, "retry-original", action)
if err != nil || retried.ID != j.ID || retried.State != "PENDING" {
t.Fatal("retry", err)
}
if _, err = s.ActArchiveJob(ctx, token, j.ID, "retry-original", action); err != nil {
t.Fatal("retry replay", err)
}
if _, err = s.ActArchiveJob(ctx, token, j.ID, "retry-stale", action); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("stale retry", err)
}
// 改策略不允许旧作业静默采用新保留窗口。
_, err = s.UpdateRetentionPolicy(ctx, token, "changed-policy", retention.UpdateInput{ExpectedRevision: 1, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 2, WorkflowHistoryDays: 2, EventLogDays: 2, DeliveryHistoryDays: 2, InboxHistoryDays: 2, AuditLogDays: 2, Reason: "new reviewed retention policy"})
if err != nil {
t.Fatal(err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
failed = mustArchiveJobs(t, ctx, s, token)[0]
if failed.State != "FAILED" || failed.ErrorCode != "POLICY_CHANGED" {
t.Fatalf("changed policy %+v", failed)
}
action = retention.JobActionInput{ExpectedRevision: failed.Revision, Action: "abandon", MaxBytes: failed.MaxBytes, Reason: "close old version; preserve files"}
closed, err := s.ActArchiveJob(ctx, token, j.ID, "close", action)
if err != nil || closed.State != "ABANDONED" {
t.Fatal("close", err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
if len(mustArchiveJobs(t, ctx, s, token)) != 1 {
t.Fatal("stale plan triggered")
}
// 无会话和异命名空间不能读取或处理作业。
other := token
other.NamespaceID = testID(t)
if _, err = s.ListArchiveJobs(ctx, other, ""); err == nil {
t.Fatal("cross namespace read")
}
if _, err = s.ActArchiveJob(ctx, other, j.ID, "cross", action); err == nil {
t.Fatal("cross namespace action")
}
// 更新计划策略后,过期的多次周期只生成一个新作业。
in.ExpectedRevision = 1
in.PolicyRevision = 2
if _, err = s.SaveArchivePlan(ctx, token, "plan-new-policy", "audit", volume, in); err != nil {
t.Fatal(err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
jobs := mustArchiveJobs(t, ctx, s, token)
if len(jobs) != 2 || jobs[0].PolicyRevision != 2 {
t.Fatalf("new policy job %+v", jobs)
}
// 暂停计划不影响已有作业结清,但不再生成后续作业。
in.ExpectedRevision = 2
in.Enabled = false
if _, err = s.SaveArchivePlan(ctx, token, "pause-plan", "audit", volume, in); err != nil {
t.Fatal(err)
}
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
jobs = mustArchiveJobs(t, ctx, s, token)
if len(jobs) != 2 || jobs[0].State != "SUCCEEDED" {
t.Fatalf("pause blocked in-flight %+v", jobs)
}
var retries int
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='retention.job.retry'`, token.NamespaceID).Scan(&retries); err != nil || retries != 1 {
t.Fatal("retry audit duplicated", err)
}
t.Log("corrupt file fails closed; restored file retries same identity exactly once; policy change blocks old job; audited close releases slot; disabled plan lets current job settle")
}
func TestIntegrationArchiveBudgetsResumeAndPagination(t *testing.T) {
ctx, db, s, token, volume := archiveFixture(t, 600000)
in := archivePlanInput()
in.MaxBytes = 1 << 20
if _, err := s.SaveArchivePlan(ctx, token, "plan", "audit", volume, in); err != nil {
t.Fatal(err)
}
runtime := &retention.ArchiveRuntime{Directory: filepath.Join(t.TempDir(), "volume"), StorageID: volume, MaxBytes: 8 << 20, Store: s}
if err := runtime.Init(); err != nil {
t.Fatal(err)
}
if err := runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
failed := mustArchiveJobs(t, ctx, s, token)[0]
if failed.State != "FAILED" || failed.ErrorCode != "STORAGE_LIMIT" {
t.Fatalf("budget was ignored %+v", failed)
}
_, err := s.ActArchiveJob(ctx, token, failed.ID, "raise-budget", retention.JobActionInput{ExpectedRevision: failed.Revision, Action: "retry", MaxBytes: 4 << 20, Reason: "reviewed file budget"})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
}
complete := mustArchiveJobs(t, ctx, s, token)[0]
if complete.ID != failed.ID || complete.State != "SUCCEEDED" || complete.Roots != 3 || complete.Bytes <= 1<<20 || complete.Bytes > 4<<20 {
t.Fatalf("budget retry %+v", complete)
}
// 全卷预算独立于单作业预算:即使单作业允许4MiB,也不能突破剩余卷容量。
if _, err = db.ExecContext(ctx, `UPDATE orbit_archive_plans SET next_run_at=TIMESTAMPADD(DAY,-1,UTC_TIMESTAMP(6)) WHERE namespace_id=?`, token.NamespaceID); err != nil {
t.Fatal(err)
}
runtime.MaxBytes = complete.Bytes + 100
if err = runtime.Tick(ctx); err != nil {
t.Fatal(err)
}
jobs := mustArchiveJobs(t, ctx, s, token)
if len(jobs) != 2 || jobs[0].State != "FAILED" || jobs[0].ErrorCode != "STORAGE_LIMIT" || jobs[0].Bytes != 0 {
t.Fatalf("volume budget %+v", jobs)
}
// 分页使用真实时间+ID顺序,数量限制由SQL完成;无效/异命名空间位置拒绝。
for i := 0; i < 24; i++ {
_, err = db.ExecContext(ctx, `INSERT INTO orbit_archive_jobs(namespace_id,id,kind,plan_revision,policy_revision,storage_id,batch_size,max_bytes,state,created_at) VALUES(?,?,'audit',1,1,?,2,1048576,'ABANDONED',TIMESTAMPADD(DAY,-2,UTC_TIMESTAMP(6)))`, token.NamespaceID, testID(t), volume)
if err != nil {
t.Fatal(err)
}
}
first, err := s.ListArchiveJobs(ctx, token, "")
if err != nil || len(first.Items) != 20 || first.NextBefore == "" {
t.Fatal("first page", err)
}
second, err := s.ListArchiveJobs(ctx, token, first.NextBefore)
if err != nil || len(second.Items) != 6 || second.NextBefore != "" {
t.Fatal("second page", err)
}
seen := map[core.ID]bool{}
for _, j := range append(first.Items, second.Items...) {
if seen[j.ID] {
t.Fatal("duplicate page item")
}
seen[j.ID] = true
}
if _, err = s.ListArchiveJobs(ctx, token, string(testID(t))); fault.Code(err) != "NOT_FOUND" {
t.Fatal("unknown page position", err)
}
t.Log("single-job byte budget stops and resumes same ID; volume budget blocks extra output; 26 jobs paginate 20+6 without duplicates")
}

View File

@@ -1,4 +1,4 @@
-- Orbit fresh-install baseline, generated from migration version 28.
-- Orbit fresh-install baseline, generated from migration version 31.
-- This file intentionally contains no database-level relation constraints.
CREATE TABLE `orbit_application_resources` (
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
@@ -814,3 +814,116 @@ CREATE TABLE `weave_waits` (
CONSTRAINT `ck_weave_waits_1` CHECK ((`state` in (_utf8mb4'WAITING',_utf8mb4'SATISFIED',_utf8mb4'EXPIRED',_utf8mb4'CANCELLED'))),
CONSTRAINT `ck_weave_waits_2` CHECK ((`kind` in (_utf8mb4'SIGNAL',_utf8mb4'TIMER',_utf8mb4'POLL')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='可恢复的信号或时间等待,不长期占用业务 Worker';
-- Orbit 控制台账号与会话;账号只用于人登录,业务 SDK 仍使用命名空间凭据。
CREATE TABLE orbit_console_users (
id BINARY(16) NOT NULL COMMENT '控制台账号标识,全局唯一',
namespace_id BINARY(16) NOT NULL COMMENT '登录后可访问的命名空间',
credential_id BINARY(16) NOT NULL COMMENT '登录后代表该账号调用 API 的业务凭据',
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',
username VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '登录名,大小写敏感',
display_name VARCHAR(128) NOT NULL COMMENT '展示名称',
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'bcrypt 密码哈希,不保存明文',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许登录',
PRIMARY KEY (id),
UNIQUE KEY uq_console_username (username),
KEY ix_console_user_principal (namespace_id, credential_id),
CONSTRAINT ck_console_users_0 CHECK (revision > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台人类账号,不替代业务 SDK 凭据';
CREATE TABLE orbit_console_sessions (
id BINARY(16) NOT NULL COMMENT '会话记录标识',
user_id BINARY(16) NOT NULL COMMENT '控制台账号标识',
namespace_id BINARY(16) NOT NULL COMMENT '会话对应命名空间',
credential_id BINARY(16) NOT NULL COMMENT '会话对应业务凭据',
token_digest BINARY(32) NOT NULL COMMENT '会话令牌摘要,不保存令牌原文',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
expires_at DATETIME(6) NOT NULL COMMENT '会话过期时间,统一 UTC',
last_seen_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最近一次使用时间,统一 UTC',
revoked_at DATETIME(6) NULL COMMENT '撤销时间;为空表示未撤销',
user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '登录时的客户端摘要',
remote_addr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录时的来源地址',
PRIMARY KEY (id),
UNIQUE KEY uq_console_session_token (token_digest),
KEY ix_console_session_user (user_id, revoked_at, expires_at),
KEY ix_console_session_expiry (expires_at, revoked_at),
CONSTRAINT ck_console_sessions_0 CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台 HttpOnly 登录会话,只保存令牌摘要';
-- 命名空间级历史保留策略;只保存窗口和开关,不执行自动删除。
-- 归档执行器必须在独立事务中核对状态、备份和归档证明后再使用这些窗口。
CREATE TABLE orbit_retention_policies (
namespace_id BINARY(16) NOT NULL COMMENT '策略所属命名空间,服务端核对登录会话后使用',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '策略乐观并发版本,从1开始;首次默认策略未落库时版本为0',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许归档执行器使用策略;默认关闭,不触发删除',
idempotency_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '请求幂等记录至少保留天数,范围90至3650',
task_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态任务和执行证据至少保留天数,范围1至3650',
workflow_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态流程、步骤、等待和流程事件至少保留天数,范围1至3650',
event_log_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '状态事件和结果事件至少保留天数,范围1至3650',
delivery_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已确认结果交付至少保留天数,未确认交付不得按窗口清理',
inbox_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已处理流程Inbox事件至少保留天数,未处理记录不得清理',
audit_log_days INT UNSIGNED NOT NULL DEFAULT 2555 COMMENT '管理审计至少保留天数,普通清理不得处理审计记录',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略首次保存时间,统一UTC',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略最后修改时间,统一UTC',
PRIMARY KEY (namespace_id),
CONSTRAINT ck_retention_policy_revision CHECK (revision > 0),
CONSTRAINT ck_retention_policy_idempotency CHECK (idempotency_days BETWEEN 90 AND 3650),
CONSTRAINT ck_retention_policy_task CHECK (task_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_workflow CHECK (workflow_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_event CHECK (event_log_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_delivery CHECK (delivery_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_inbox CHECK (inbox_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_audit CHECK (audit_log_days BETWEEN 1 AND 3650)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';
-- 周期计划、固定范围作业和领取租约;不使用数据库外键,不删除历史源记录。
CREATE TABLE orbit_archive_plans (
namespace_id BINARY(16) NOT NULL COMMENT '所属命名空间,由事务核验',
kind VARCHAR(16) NOT NULL COMMENT '固定历史类别,同一命名空间每类一个计划',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '配置版本,后台触发不修改此版本',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否触发新作业,不中断已有作业',
policy_revision BIGINT UNSIGNED NOT NULL COMMENT '管理员确认的保留策略版本',
storage_id BINARY(16) NOT NULL COMMENT '固定归档卷身份,接管节点必须挂载同一物理卷',
interval_seconds INT UNSIGNED NOT NULL COMMENT '周期秒数,3600至2592000,错过多次合并一次',
batch_size INT UNSIGNED NOT NULL COMMENT '每批根记录上限,1至100',
max_bytes BIGINT UNSIGNED NOT NULL COMMENT '新作业累计文件字节预算,1MiB至1GiB',
next_run_at DATETIME(6) NOT NULL COMMENT '下次触发UTC时间,首次启用立即到期',
active_job_id BINARY(16) NULL COMMENT '未完成或失败待处理作业,成功或关闭后为空',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '首次保存UTC时间',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '配置最后修改UTC时间',
PRIMARY KEY(namespace_id,kind),
KEY ix_archive_plan_due(storage_id,enabled,next_run_at),
CONSTRAINT ck_archive_plan_kind CHECK(kind IN ('tasks','workflows','events','audit','deliveries','inbox','idempotency')),
CONSTRAINT ck_archive_plan_config CHECK(revision>0 AND policy_revision>0 AND interval_seconds BETWEEN 3600 AND 2592000 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='按命名空间和类别配置的周期归档计划,无外键';
CREATE TABLE orbit_archive_jobs (
namespace_id BINARY(16) NOT NULL COMMENT '所属命名空间,由事务核验',
id BINARY(16) NOT NULL COMMENT '作业及目录及文件清单的固定标识',
kind VARCHAR(16) NOT NULL COMMENT '创建时固定的历史类别',
plan_revision BIGINT UNSIGNED NOT NULL COMMENT '创建时固定的计划版本',
policy_revision BIGINT UNSIGNED NOT NULL COMMENT '创建时固定的保留策略版本',
storage_id BINARY(16) NOT NULL COMMENT '固定归档卷身份,不允许跨独立磁盘接管',
batch_size INT UNSIGNED NOT NULL COMMENT '创建时固定的单批根记录上限',
max_bytes BIGINT UNSIGNED NOT NULL COMMENT '累计文件字节预算,人工重试仅可提高',
state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING待领取RUNNING执行中SUCCEEDED完成FAILED待处理ABANDONED已关闭',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态并发版本,领取确认和人工操作递增',
attempts INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计领取次数,包含分批继续和崩溃接管',
batches INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已验证并入库的连续文件批次数',
roots BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认根记录总数',
bytes BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认清单及批次总字节数',
last_sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '已确认文件链尾摘要,恢复必须验证此前缀',
error_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '固定失败分类,不记录原始异常或业务内容',
lease_id BINARY(16) NULL COMMENT '领取随机代次,非执行中为空',
lease_until DATETIME(6) NULL COMMENT 'UTC租约截止,过期可接管,非执行中为空',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '作业创建UTC时间',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '状态最后变更UTC时间',
PRIMARY KEY(namespace_id,id),
KEY ix_archive_job_claim(storage_id,state,lease_until,updated_at),
KEY ix_archive_job_list(namespace_id,created_at,id),
CONSTRAINT ck_archive_job_state CHECK(state IN ('PENDING','RUNNING','SUCCEEDED','FAILED','ABANDONED')),
CONSTRAINT ck_archive_job_config CHECK(revision>0 AND plan_revision>0 AND policy_revision>0 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824),
CONSTRAINT ck_archive_job_lease CHECK((state='RUNNING' AND lease_id IS NOT NULL AND lease_until IS NOT NULL) OR (state<>'RUNNING' AND lease_id IS NULL AND lease_until IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='持久归档作业及租约,只读源数据,无外键';

View File

@@ -5,11 +5,13 @@ import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"golang.org/x/crypto/bcrypt"
)
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
@@ -30,16 +32,20 @@ type BootstrapInput struct {
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
}
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
type BootstrapResult struct {
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
}
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
@@ -54,6 +60,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
if !service.ValidName(in.Environment, 32) || deliver == nil {
return fmt.Errorf("environment and secure delivery are required")
}
if (in.ConsoleUsername == "") != (in.ConsolePassword == "") || (in.ConsoleUsername == "" && in.ConsoleDisplayName != "") {
return fmt.Errorf("console username and password must be provided together")
}
if err := CheckSchema(ctx, db); err != nil {
return err
}
@@ -73,6 +82,11 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
}
*dest = v
}
if in.ConsoleUsername != "" {
if err := validateConsoleUserInput(ConsoleUserInput{NamespaceID: out.NamespaceID, CredentialID: out.CredentialID, Username: in.ConsoleUsername, DisplayName: in.ConsoleDisplayName, Password: in.ConsolePassword}); err != nil {
return err
}
}
secret, digest, err := auth.Issue(out.NamespaceID, out.CredentialID)
if err != nil {
return err
@@ -118,6 +132,19 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
if err := permissions.Validate(); err != nil {
return err
}
var consolePasswordHash []byte
var consoleUserID core.ID
if in.ConsoleUsername != "" {
consolePasswordHash, err = bcrypt.GenerateFromPassword([]byte(in.ConsolePassword), consolePasswordCost)
if err != nil {
return err
}
consoleUserID, err = core.NewID()
if err != nil {
return err
}
out.ConsoleUsername = in.ConsoleUsername
}
raw, err := json.Marshal(permissions)
if err != nil {
return err
@@ -150,17 +177,37 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
return err
}
op, err := core.NewID()
actor, err := databaseActor(ctx, tx)
if err != nil {
return err
}
actor, err := databaseActor(ctx, tx)
op, err := core.NewID()
if err != nil {
return err
}
if err := appendAuditActor(ctx, tx, out.NamespaceID, actor, op, "bootstrap.create", out.CredentialID, "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil {
return err
}
if in.ConsoleUsername != "" {
name := strings.TrimSpace(in.ConsoleDisplayName)
if name == "" {
name = in.ConsoleUsername
}
if _, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_users
(id,namespace_id,credential_id,username,display_name,password_hash)
VALUES (?,?,?,?,?,?)`, consoleUserID, out.NamespaceID, out.CredentialID, in.ConsoleUsername, name, string(consolePasswordHash)); err != nil {
return err
}
consoleOperationID, err := core.NewID()
if err != nil {
return err
}
if err := appendAuditActor(ctx, tx, out.NamespaceID, actor, consoleOperationID, "console_user.create", consoleUserID, "bootstrap console administrator", nil, map[string]any{
"username": in.ConsoleUsername, "display_name": name, "credential_id": out.CredentialID,
}); err != nil {
return err
}
}
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。
return deliver(out)
})

View File

@@ -0,0 +1,35 @@
package mysql
import (
"context"
"strings"
"testing"
)
func TestBootstrapConsoleAdministratorRequiresUsernameAndPassword(t *testing.T) {
base := BootstrapInput{Application: "app", Namespace: "default", Environment: "test", Queue: "queue"}
for name, in := range map[string]BootstrapInput{
"username without password": func() BootstrapInput {
in := base
in.ConsoleUsername = "admin"
return in
}(),
"password without username": func() BootstrapInput {
in := base
in.ConsolePassword = "a sufficiently long password"
return in
}(),
"display name without account": func() BootstrapInput {
in := base
in.ConsoleDisplayName = "Orbit 管理员"
return in
}(),
} {
t.Run(name, func(t *testing.T) {
err := Bootstrap(context.Background(), nil, in, func(BootstrapResult) error { return nil })
if err == nil || !strings.Contains(err.Error(), "console username and password") {
t.Fatalf("expected console administrator validation error, got %v", err)
}
})
}
}

View File

@@ -0,0 +1,217 @@
package mysql
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"unicode/utf8"
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"golang.org/x/crypto/bcrypt"
)
const (
// ConsoleSessionLifetime 是控制台登录会话的最长空闲前有效期。
ConsoleSessionLifetime = 12 * time.Hour
// 登录不是业务热路径,使用高于 bcrypt 默认值的成本换取更高的离线破解成本。
consolePasswordCost = 12
)
// ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。
// 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围;
// 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。
type ConsoleAuthStore struct{ db *sql.DB }
// NewConsoleAuthStore 创建控制台认证存储。
func NewConsoleAuthStore(db *sql.DB) *ConsoleAuthStore { return &ConsoleAuthStore{db: db} }
var _ consoleauth.Store = (*ConsoleAuthStore)(nil)
// ConsoleUser 和 ConsoleLogin 由 HTTP/数据库共享,避免认证实现与路由互相依赖。
type ConsoleUser = consoleauth.User
type ConsoleLogin = consoleauth.LoginResult
// ConsoleUserInput 描述创建控制台账号所需的管理输入。
// NamespaceID 和 CredentialID 必须指向同一命名空间下已存在的业务凭据。
type ConsoleUserInput struct {
NamespaceID core.ID
CredentialID core.ID
Username string
DisplayName string
Password string
}
// CreateConsoleUser 创建一个控制台账号,只保存 bcrypt 哈希,不保存密码原文。
func (s *ConsoleAuthStore) CreateConsoleUser(ctx context.Context, in ConsoleUserInput) (ConsoleUser, error) {
if s == nil || s.db == nil {
return ConsoleUser{}, errors.New("console auth database unavailable")
}
if err := validateConsoleUserInput(in); err != nil {
return ConsoleUser{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), consolePasswordCost)
if err != nil {
return ConsoleUser{}, err
}
id, err := core.NewID()
if err != nil {
return ConsoleUser{}, err
}
operationID, err := core.NewID()
if err != nil {
return ConsoleUser{}, err
}
name := strings.TrimSpace(in.DisplayName)
if name == "" {
name = in.Username
}
if err := CheckSchema(ctx, s.db); err != nil {
return ConsoleUser{}, err
}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_users
(id,namespace_id,credential_id,username,display_name,password_hash)
SELECT ?,?,?,?,?,? FROM orbit_credentials c
JOIN orbit_namespaces n ON n.id=c.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
AND n.enabled=TRUE AND a.enabled=TRUE`,
id, in.NamespaceID, in.CredentialID, in.Username, name, string(hash), in.NamespaceID, in.CredentialID)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return fault.Conflict
}
return err
}
if affected, err := result.RowsAffected(); err != nil {
return err
} else if affected != 1 {
return fault.NotFound
}
actor, err := databaseActor(ctx, tx)
if err != nil {
return err
}
return appendAuditActor(ctx, tx, in.NamespaceID, actor, operationID, "console_user.create", id, "create console user", nil, map[string]any{
"username": in.Username, "display_name": name, "credential_id": in.CredentialID,
})
})
if err != nil {
return ConsoleUser{}, err
}
return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil
}
// Login 校验账号密码并建立一个独立会话。失败时统一返回未认证,避免泄露账号存在性。
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
if s == nil || s.db == nil || username == "" || password == "" || len(password) > 1024 {
return ConsoleLogin{}, fault.Unauthorized
}
var user ConsoleUser
var userID core.ID
var namespace, credential core.ID
var hash string
var enabled bool
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.password_hash,u.enabled,u.namespace_id,u.credential_id
FROM orbit_console_users u
JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id
JOIN orbit_namespaces n ON n.id=u.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE u.username=? AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
AND n.enabled=TRUE AND a.enabled=TRUE`, username).
Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
if err != nil || !enabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return ConsoleLogin{}, fault.Unauthorized
}
user.ID = string(userID)
token, secretDigest, err := auth.Issue(namespace, credential)
if err != nil {
return ConsoleLogin{}, err
}
var now time.Time
if err := s.db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
return ConsoleLogin{}, err
}
now = now.UTC()
expires := now.Add(ConsoleSessionLifetime)
sessionID, err := core.NewID()
if err != nil {
return ConsoleLogin{}, err
}
if len(userAgent) > 512 {
userAgent = userAgent[:512]
}
if len(remoteAddr) > 64 {
remoteAddr = remoteAddr[:64]
}
_, err = s.db.ExecContext(ctx, `INSERT INTO orbit_console_sessions
(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr)
VALUES (?,?,?,?,?,?,UTC_TIMESTAMP(6),?,?)`,
sessionID, userID, namespace, credential, secretDigest, expires, userAgent, remoteAddr)
if err != nil {
return ConsoleLogin{}, err
}
return ConsoleLogin{User: user, Token: token, ExpiresAt: expires}, nil
}
// ValidateSession 检查会话是否仍然有效,并更新最近使用时间。
// API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。
func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error {
if s == nil || s.db == nil {
return fault.Unauthorized
}
var one int
err := s.db.QueryRowContext(ctx, `SELECT 1
FROM orbit_console_sessions s
JOIN orbit_console_users u ON u.id=s.user_id
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
JOIN orbit_namespaces n ON n.id=s.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
AND u.enabled=TRUE AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
AND n.enabled=TRUE AND a.enabled=TRUE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return fault.Unauthorized
}
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `UPDATE orbit_console_sessions SET last_seen_at=UTC_TIMESTAMP(6)
WHERE namespace_id=? AND credential_id=? AND token_digest=? AND revoked_at IS NULL`, token.NamespaceID, token.CredentialID, token.Digest)
return err
}
// RevokeSession 使当前浏览器会话立即失效;找不到会话时也按成功处理,便于幂等退出。
func (s *ConsoleAuthStore) RevokeSession(ctx context.Context, token auth.Token) error {
if s == nil || s.db == nil {
return nil
}
_, err := s.db.ExecContext(ctx, `UPDATE orbit_console_sessions SET revoked_at=UTC_TIMESTAMP(6)
WHERE namespace_id=? AND credential_id=? AND token_digest=? AND revoked_at IS NULL`, token.NamespaceID, token.CredentialID, token.Digest)
return err
}
func validateConsoleUserInput(in ConsoleUserInput) error {
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil {
return fault.Invalid("namespace and credential are required")
}
if in.Username == "" || len(in.Username) > 128 || !utf8.ValidString(in.Username) || strings.TrimSpace(in.Username) != in.Username {
return fault.Invalid("username must be 1-128 valid UTF-8 characters")
}
if in.DisplayName != "" && (len(in.DisplayName) > 128 || !utf8.ValidString(in.DisplayName)) {
return fault.Invalid("display name must be at most 128 UTF-8 bytes")
}
if len(in.Password) < 12 || len(in.Password) > 1024 {
return fault.Invalid("password must be 12-1024 bytes")
}
return nil
}

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 28
const freshInstallBaselineVersion uint64 = 31
type Migration struct {
Version uint64

Some files were not shown because too many files have changed in this diff Show More