feat: add read-only retention inventory

This commit is contained in:
2026-09-19 17:21:01 +08:00
parent 6a251e5843
commit da5874b17a
9 changed files with 184 additions and 3 deletions

View File

@@ -23,17 +23,19 @@ func main() {
}
func run() error {
if len(os.Args) < 2 {
return errors.New("usage: orbit-admin bootstrap|verify|credential status|rotate|revoke|apply")
return errors.New("usage: orbit-admin bootstrap|verify|retention inventory|credential status|rotate|revoke|apply")
}
switch os.Args[1] {
case "bootstrap":
return runBootstrap(os.Args[2:])
case "verify":
return runVerify(os.Args[2:])
case "retention":
return runRetention(os.Args[2:])
case "credential":
return runCredential(os.Args[2:])
}
return errors.New("usage: orbit-admin bootstrap|verify|credential status|rotate|revoke|apply")
return errors.New("usage: orbit-admin bootstrap|verify|retention inventory|credential status|rotate|revoke|apply")
}
func runVerify(args []string) error {
@@ -67,6 +69,43 @@ func runVerify(args []string) error {
return nil
}
func runRetention(args []string) error {
if len(args) == 0 || args[0] != "inventory" {
return errors.New("usage: orbit-admin retention inventory --before RFC3339")
}
flags := flag.NewFlagSet("retention inventory", flag.ContinueOnError)
var before string
flags.StringVar(&before, "before", "", "UTC cutoff; only records strictly before it are counted")
if err := flags.Parse(args[1:]); err != nil {
return err
}
if flags.NArg() != 0 || before == "" {
return errors.New("--before RFC3339 is required")
}
cutoff, err := time.Parse(time.RFC3339Nano, before)
if err != nil {
return errors.New("--before must be RFC3339")
}
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()
inventory, err := mysql.ReadRetentionInventory(ctx, db, cutoff)
encoded, encodeErr := json.Marshal(inventory)
if encodeErr != nil {
return encodeErr
}
fmt.Println(string(encoded))
return err
}
func runBootstrap(args []string) error {
flags := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
var in mysql.BootstrapInput

View File

@@ -159,4 +159,6 @@ Orbit 数据库事务不能自动包含 接入方的业务事务。SDK 的事务
- 恢复旧备份后,可能已有外部业务效果而平台记录回退。必须停止派发、使恢复前的会话与租约失效,并完成必要对账后再开放执行。
- 数据库故障切换与备份恢复是不同故障模型,分别定义验证和可接受损失范围。
当前可用的[`orbit-admin retention inventory`](api/retention.md)只提供按 UTC 截止时间的数量盘点,不执行归档或删除;保留周期、归档格式和受保护记录核对完成前,不得把盘点结果当作清理授权。
MySQL 持久化建议基线为 `innodb_flush_log_at_trx_commit=1`,启用 binlog 时采用 `sync_binlog=1`。副本故障切换的确认语义、RPO / RTO 仍要由具体高可用方案决定。[MySQL 持久化配置](https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit)

View File

@@ -61,7 +61,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。
2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定状态计数、最近事件类别、终态耗时样本、最早年龄、截断/新鲜度指标和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供。仍需受理/授权阶段端到端延迟与全局持久吞吐、流程Inbox推进延迟、全局发送队列、多节点聚合及生产采集/通知验证。
3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,撤销允许在停用范围内执行且不改写历史记录。仍需补齐提交不明故障切点、多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。
4. 实现保留/归档与受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
4. 已补[`orbit-admin retention inventory`](api/retention.md)只读盘点和[阶段报告](reports/phase-6-retention-inventory.md),能区分过期幂等、确定终态、未知效果、未终结流程、已确认/未确认交付、已消费/未消费Inbox、事件和审计规模;它不执行归档或删除。后续仍需评审各类保留周期、归档格式、受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
### P3 功能范围核对

View File

@@ -21,6 +21,7 @@
| 任务 `priority` 与节点领取池 | P3 已实现有界优先级加速、应用/队列轮转 | [优先级与公平派发](priority-and-fairness.md) |
| 凭据校验、初始化、固定操作文件轮换/撤销与回执重放 CLI | P1/P6 已实现;不提供公网管理路由,测试专用提交不明切点已回归 | [鉴权](authentication.md)、[凭据报告](../reports/phase-6-credentials.md) |
| `orbit-admin verify` 发布前结构核验 | 已实现只读迁移、零外键和 000026–000028 固定索引检查 | [运维核验](operations.md) |
| `orbit-admin retention inventory` 保留清单 | 已实现带 UTC 截止时间的只读盘点;不执行归档、删除或权限变更 | [保留清单](retention.md) |
| `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) |
| 任务相对延迟 `delay_ms` | 已实现数据库计时、原键恢复与实际执行例子 | [任务接口](tasks.md)、[延迟示例](../examples/delayed-task.md) |
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |

19
docs/api/retention.md Normal file
View File

@@ -0,0 +1,19 @@
# 保留清单(只读)
`orbit-admin retention inventory` 用于在制定保留窗口、归档和备份恢复方案前盘点历史数据。它只执行带截止时间的统计查询,不删除、搬迁、改写数据,也不会自动判断某条记录可以安全归档。
```sh
ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' \
go run ./cmd/orbit-admin retention inventory \
--before 2026-09-18T00:00:00Z
```
示例输出:
```json
{"cutoff":"2026-09-18T00:00:00Z","expired_idempotency":0,"terminal_tasks":0,"uncertain_tasks":0,"active_tasks":0,"terminal_workflows":0,"active_workflows":0,"acknowledged_deliveries":0,"unconfirmed_deliveries":0,"processed_inbox":0,"unprocessed_inbox":0,"events":0,"audit_events":0}
```
所有时间按 UTC 解析,严格早于 `--before` 的记录才计入。截止时间不能晚于数据库当前时间,避免把未来记录误认为历史。`terminal_tasks` 只统计确定终态且 `effect_uncertain=false` 的任务;`uncertain_tasks`、`active_tasks`、`active_workflows`、`unconfirmed_deliveries` 和 `unprocessed_inbox` 是需要先处理或保留的阻塞项。
结果是盘点快照,不是归档授权。真正的清理或归档还必须完成保留周期评审、未确认副作用核对、事件投递和流程推进核对、备份恢复演练、权限隔离及审计;当前命令没有 `apply` 或删除路径。

View File

@@ -0,0 +1,25 @@
# P6:保留清单与归档前置核对
日期:2026-09-19。本批先交付只读盘点,避免在保留周期、备份恢复和外部效果尚未评审时提供危险的自动删除入口。
## 已实现
- [`ReadRetentionInventory`](../../internal/storage/mysql/retention_inventory.go) 接受明确的 UTC 截止时间,只统计严格早于截止时间的记录。
- `orbit-admin retention inventory --before RFC3339` 通过独立管理命令输出 JSON;数据库时间校验会拒绝未来截止时间,数据库连接、查询失败不会泄露 DSN 或 SQL。
- 盘点固定覆盖幂等、确定终态/未知效果/未终结任务、确定终态/未终结流程、已确认/未确认交付、已消费/未消费流程 Inbox、追加事件和管理审计。
- 任何查询只读,不执行 DDL、删除、搬迁、权限修改或幂等重置;CLI 没有 `apply` 参数。未确认交付、未消费 Inbox、未知效果和未终结实体被单独列为阻塞数量。
## 回归证据
- `go test ./cmd/orbit-admin ./internal/storage/mysql` 通过;存储集成用例使用真实 MySQL 验证:明确放到截止时间之前的确定终态任务计入 `terminal_tasks`,未知效果为零,未来截止被拒绝。
- 标准 `DOCKER=... GO=... sh scripts/test-mysql.sh` 在 Go 1.27.1、Docker MySQL 8.4.10 环境退出码为 0。脚本实际输出:
```text
{"cutoff":"2000-01-01T00:00:00Z","expired_idempotency":0,"terminal_tasks":0,"uncertain_tasks":0,"active_tasks":0,"terminal_workflows":0,"active_workflows":0,"acknowledged_deliveries":0,"unconfirmed_deliveries":0,"processed_inbox":0,"unprocessed_inbox":0,"events":0,"audit_events":0}
```
同轮已有迁移、零外键、凭据、Pulse/Weave 真实进程强杀恢复、SDK 和两个独立业务示例均通过。
## 边界与后续
盘点数量不是归档授权,也不证明某类记录可独立删除。正式归档前仍需确定每类保留周期和归档格式,核对事件交付、流程推进、有效幂等身份、未知外部效果、审计保留与备份恢复;还需要隔离环境恢复演练、权限隔离、容量和生产拓扑验证。当前没有自动清理路径,防止把测试环境的历史数量误当成生产策略。

View File

@@ -98,4 +98,15 @@ func TestIntegrationOperationalSnapshot(t *testing.T) {
if _, err := ReadOperationalSnapshot(cancelled, db); err == nil {
t.Fatal("cancelled context unexpectedly completed snapshot")
}
f.exec("UPDATE pulse_tasks SET completed_at=DATE_SUB(UTC_TIMESTAMP(6), INTERVAL 2 HOUR), updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?", f.resources.NamespaceID, task.ID)
inventory, err := ReadRetentionInventory(ctx, db, time.Now().UTC().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if inventory.Cutoff.IsZero() || inventory.TerminalTasks == 0 || inventory.UncertainTasks != 0 {
t.Fatalf("retention inventory=%+v", inventory)
}
if _, err := ReadRetentionInventory(ctx, db, time.Now().UTC().Add(time.Hour)); err == nil {
t.Fatal("future retention cutoff unexpectedly accepted")
}
}

View File

@@ -0,0 +1,83 @@
package mysql
import (
"context"
"database/sql"
"errors"
"time"
)
// RetentionInventory 是指定截止时间前的只读保留清单。
// 它只提供核对数量,不代表记录已经满足归档条件,也不执行删除或搬迁。
type RetentionInventory struct {
// Cutoff 是清单使用的 UTC 截止时间,严格早于该时间的记录才会计入。
Cutoff time.Time `json:"cutoff"`
// ExpiredIdempotency 是已过幂等保留期的请求记录数量。
ExpiredIdempotency uint64 `json:"expired_idempotency"`
// TerminalTasks 是已进入确定终态且已完成的任务数量。
TerminalTasks uint64 `json:"terminal_tasks"`
// UncertainTasks 是仍带未知效果的任务数量,必须人工核对后才能处理。
UncertainTasks uint64 `json:"uncertain_tasks"`
// ActiveTasks 是尚未终结的任务数量,不能进入普通历史归档。
ActiveTasks uint64 `json:"active_tasks"`
// TerminalWorkflows 是已进入确定终态且已完成的流程数量。
TerminalWorkflows uint64 `json:"terminal_workflows"`
// ActiveWorkflows 是尚未终结的流程数量,不能进入普通历史归档。
ActiveWorkflows uint64 `json:"active_workflows"`
// AcknowledgedDeliveries 是已经确认的结果交付数量。
AcknowledgedDeliveries uint64 `json:"acknowledged_deliveries"`
// UnconfirmedDeliveries 是仍未确认的结果交付数量,不能无声清理。
UnconfirmedDeliveries uint64 `json:"unconfirmed_deliveries"`
// ProcessedInbox 是已经完成内部消费的流程事件数量。
ProcessedInbox uint64 `json:"processed_inbox"`
// UnprocessedInbox 是尚未完成内部消费的流程事件数量,不能清理。
UnprocessedInbox uint64 `json:"unprocessed_inbox"`
// Events 是截止时间前追加的不可变事件数量。
Events uint64 `json:"events"`
// AuditEvents 是截止时间前追加的管理审计数量,默认应单独保留。
AuditEvents uint64 `json:"audit_events"`
}
// ReadRetentionInventory performs bounded-by-time, read-only count queries.
// It deliberately does not decide policy or mutate any table; callers must
// review retention windows, backups, outstanding effects and audit rules first.
func ReadRetentionInventory(ctx context.Context, db *sql.DB, cutoff time.Time) (RetentionInventory, error) {
if db == nil {
return RetentionInventory{}, errors.New("database is required")
}
if cutoff.IsZero() {
return RetentionInventory{}, errors.New("cutoff is required")
}
cutoff = cutoff.UTC()
var now time.Time
if err := db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
return RetentionInventory{}, errors.New("database time unavailable")
}
if cutoff.After(now.UTC()) {
return RetentionInventory{}, errors.New("cutoff cannot be in the future")
}
result := RetentionInventory{Cutoff: cutoff}
queries := []struct {
destination *uint64
query string
}{
{&result.ExpiredIdempotency, "SELECT COUNT(*) FROM orbit_idempotency_keys WHERE expires_at<?"},
{&result.TerminalTasks, "SELECT COUNT(*) FROM pulse_tasks WHERE state IN ('SUCCEEDED','FAILED','CANCELLED') AND completed_at IS NOT NULL AND completed_at<? AND effect_uncertain=FALSE"},
{&result.UncertainTasks, "SELECT COUNT(*) FROM pulse_tasks WHERE effect_uncertain=TRUE AND created_at<?"},
{&result.ActiveTasks, "SELECT COUNT(*) FROM pulse_tasks WHERE state NOT IN ('SUCCEEDED','FAILED','CANCELLED') AND created_at<?"},
{&result.TerminalWorkflows, "SELECT COUNT(*) FROM weave_instances WHERE state IN ('SUCCEEDED','FAILED','CANCELLED') AND completed_at IS NOT NULL AND completed_at<?"},
{&result.ActiveWorkflows, "SELECT COUNT(*) FROM weave_instances WHERE state NOT IN ('SUCCEEDED','FAILED','CANCELLED') AND created_at<?"},
{&result.AcknowledgedDeliveries, "SELECT COUNT(*) FROM orbit_deliveries WHERE state='ACKED' AND acknowledged_at IS NOT NULL AND acknowledged_at<?"},
{&result.UnconfirmedDeliveries, "SELECT COUNT(*) FROM orbit_deliveries WHERE state<>'ACKED' AND created_at<?"},
{&result.ProcessedInbox, "SELECT COUNT(*) FROM weave_inbox WHERE state='PROCESSED' AND processed_at IS NOT NULL AND processed_at<?"},
{&result.UnprocessedInbox, "SELECT COUNT(*) FROM weave_inbox WHERE state<>'PROCESSED' AND created_at<?"},
{&result.Events, "SELECT COUNT(*) FROM orbit_events WHERE occurred_at<?"},
{&result.AuditEvents, "SELECT COUNT(*) FROM orbit_audit_events WHERE created_at<?"},
}
for _, item := range queries {
if err := db.QueryRowContext(ctx, item.query, cutoff).Scan(item.destination); err != nil {
return RetentionInventory{}, errors.New("retention inventory unavailable")
}
}
return result, nil
}

View File

@@ -45,6 +45,7 @@ ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" "$TASK_GO" test -race -count=1 -
"$TASK_GO" build -o "$TASK_TEST_DIR/orbit-admin" ./cmd/orbit-admin
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-migrate" status
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" verify
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" retention inventory --before 2000-01-01T00:00:00Z
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-example --namespace default --environment test --queue files --output "$TASK_TEST_DIR/sdk.credentials.json"
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" ORBIT_HTTP_ADDR=127.0.0.1:0 "$TASK_TEST_DIR/orbit" > "$TASK_TEST_DIR/server.log" 2>&1 &
TASK_SERVER_PID=$!