fix: complete retention archive pagination and validation
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
@@ -168,11 +169,19 @@ func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
|
||||
}
|
||||
ids = append(ids, string(out.Task.ID))
|
||||
}
|
||||
completedAt := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Microsecond)
|
||||
for _, id := range ids {
|
||||
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=UTC_TIMESTAMP(6)-INTERVAL 2 DAY,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, boot.NamespaceID, id); err != nil {
|
||||
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, completedAt, boot.NamespaceID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 同一完成时间验证复合游标;未知效果和活跃租约不能因时间足够旧而被导出。
|
||||
if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='NEEDS_REVIEW',completed_at=NULL,effect_uncertain=TRUE WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='RUNNING',completed_at=NULL,current_attempt_id=UNHEX('00000000000000000000000000000001'),lease_until=UTC_TIMESTAMP(6)+INTERVAL 1 HOUR WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -213,7 +222,103 @@ func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
|
||||
if snapshot.SHA256 != hex.EncodeToString(sum[:]) || !json.Valid([]byte(snapshot.Content)) || !strings.Contains(snapshot.Content, `"source_retained":true`) {
|
||||
t.Fatalf("retention snapshot integrity/source marker failed: sha=%s content=%s", snapshot.SHA256, snapshot.Content[:min(len(snapshot.Content), 200)])
|
||||
}
|
||||
t.Logf("bounded retention growth sample inserted %d terminal tasks; bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
|
||||
var first archiveData
|
||||
if err := json.Unmarshal([]byte(snapshot.Content), &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.NextCursor == "" {
|
||||
t.Fatal("first page lost continuation cursor")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
page := first
|
||||
batches := 0
|
||||
for {
|
||||
batches++
|
||||
if page.Cutoff != first.Cutoff || page.Policy.Revision != 1 || !page.SourceRetained {
|
||||
t.Fatalf("batch changed boundary: %+v", page.Policy)
|
||||
}
|
||||
if page.RootCount != len(page.Tables["pulse_tasks"]) {
|
||||
t.Fatal("root count mismatch")
|
||||
}
|
||||
for _, row := range page.Tables["pulse_tasks"] {
|
||||
id, ok := row["id"].(string)
|
||||
if !ok || seen[id] || id == ids[0] || id == ids[1] {
|
||||
t.Fatalf("duplicate/unsafe root: %v", row["id"])
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
if batches > 10 {
|
||||
t.Fatal("cursor did not make bounded progress")
|
||||
}
|
||||
next, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", Limit: 37, Cursor: page.NextCursor, ExpectedRevision: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(next.Content))
|
||||
if next.SHA256 != hex.EncodeToString(digest[:]) {
|
||||
t.Fatal("batch digest mismatch")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(next.Content), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(ids)-2 {
|
||||
t.Fatalf("exported %d roots; want %d", len(seen), len(ids)-2)
|
||||
}
|
||||
var retained int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=?", boot.NamespaceID).Scan(&retained); err != nil || retained != len(ids) {
|
||||
t.Fatalf("source data changed: count=%d err=%v", retained, err)
|
||||
}
|
||||
for _, in := range []retention.ArchiveInput{
|
||||
{Kind: "audit", Cursor: first.NextCursor, ExpectedRevision: 1},
|
||||
{Kind: "tasks", Cursor: "not-a-cursor", ExpectedRevision: 1},
|
||||
{Kind: "tasks", Limit: 101, ExpectedRevision: 1},
|
||||
} {
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, in); err == nil {
|
||||
t.Fatalf("invalid export accepted: %+v", in)
|
||||
}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(first.NextCursor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var foreign archiveCursor
|
||||
if err := json.Unmarshal(raw, &foreign); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign.Namespace = testID(t)
|
||||
raw, err = json.Marshal(foreign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: base64.RawURLEncoding.EncodeToString(raw)}); err == nil {
|
||||
t.Fatal("foreign namespace cursor accepted")
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, apiToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1}); err == nil {
|
||||
t.Fatal("business bearer token accepted as console admin")
|
||||
}
|
||||
policyChange := retention.UpdateInput{ExpectedRevision: 1, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 2, WorkflowHistoryDays: 30, EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30, AuditLogDays: 365, Reason: "verify frozen cursor boundary"}
|
||||
if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-revise", policyChange); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: first.NextCursor}); faultCode(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("stale policy allowed: %v", err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 2, Cursor: first.NextCursor}); err == nil {
|
||||
t.Fatal("old cursor accepted with new policy")
|
||||
}
|
||||
policyChange.ExpectedRevision, policyChange.Enabled = 2, false
|
||||
if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-disable", policyChange); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 3}); faultCode(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("disabled policy allowed: %v", err)
|
||||
}
|
||||
t.Logf("archive pagination: batches=%d unique_roots=%d protected_roots=2 source_rows=%d; digest, namespace, kind, revision, disabled policy and console-only checks passed", batches, len(seen), retained)
|
||||
t.Logf("bounded retention growth sample inserted %d tasks (254 terminal, 2 protected); bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
|
||||
}
|
||||
|
||||
func retentionTableBytes(t *testing.T, ctx context.Context, db interface {
|
||||
|
||||
Reference in New Issue
Block a user