feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -91,9 +91,10 @@ func (p *DeleteProcessor) Handle(ctx context.Context, event outbox.Event) error
|
||||
}
|
||||
outcome, note, runErr := p.executeSubtask(ctx, request, task.subsystem)
|
||||
if runErr != nil {
|
||||
message := safeDeletionError(runErr)
|
||||
_, _ = p.pool.Exec(ctx, `UPDATE privacy_subtask SET status='FAILED',last_error=$2,next_attempt_at=now()+interval '30 seconds',updated_at=now() WHERE id=$1::uuid`, task.id, message)
|
||||
_, _ = p.pool.Exec(ctx, `UPDATE privacy_request SET status='PARTIAL_FAILED',result_summary=jsonb_build_object('failed_subsystem',$2,'content_included',false),version=version+1,updated_at=now() WHERE id=$1::uuid`, request.id, task.subsystem)
|
||||
runErr = deletionRetryError(runErr)
|
||||
if stateErr := p.recordSubtaskFailure(ctx, request, task, runErr); stateErr != nil {
|
||||
return errors.Join(fmt.Errorf("delete subject subsystem %s: %w", task.subsystem, runErr), fmt.Errorf("record deletion failure: %w", stateErr))
|
||||
}
|
||||
return fmt.Errorf("delete subject subsystem %s: %w", task.subsystem, runErr)
|
||||
}
|
||||
if _, err := p.pool.Exec(ctx, `UPDATE privacy_subtask SET status=$2,last_error=NULLIF($3,''),next_attempt_at=NULL,completed_at=now(),updated_at=now() WHERE id=$1::uuid`, task.id, outcome, note); err != nil {
|
||||
@@ -121,6 +122,31 @@ func (p *DeleteProcessor) Handle(ctx context.Context, event outbox.Event) error
|
||||
})
|
||||
}
|
||||
|
||||
// Keep the queue's actual retry delay aligned with the receipt's advertised
|
||||
// cooldown. Otherwise a dependency without Retry-After can exhaust the worker's
|
||||
// attempts while the detail page still says the first retry is in the future.
|
||||
func deletionRetryError(err error) error {
|
||||
if outbox.IsRetryable(err) {
|
||||
return outbox.RetryAfter(err, 30*time.Second)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *DeleteProcessor) recordSubtaskFailure(ctx context.Context, request deletionRequest, task deletionSubtask, runErr error) error {
|
||||
var nextAttempt *time.Time
|
||||
if outbox.IsRetryable(runErr) {
|
||||
next := time.Now().Add(max(30*time.Second, outbox.RetryDelay(runErr)))
|
||||
nextAttempt = &next
|
||||
}
|
||||
return database.Transact(ctx, p.pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `UPDATE privacy_subtask SET status='FAILED',last_error=$2,next_attempt_at=$3,updated_at=now() WHERE id=$1::uuid`, task.id, safeDeletionError(runErr), nextAttempt); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE privacy_request SET status='PARTIAL_FAILED',result_summary=jsonb_build_object('failed_subsystem',$2::text,'content_included',false),version=version+1,updated_at=now() WHERE id=$1::uuid`, request.id, task.subsystem)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (p *DeleteProcessor) loadRequest(ctx context.Context, id string) (deletionRequest, error) {
|
||||
var value deletionRequest
|
||||
err := p.pool.QueryRow(ctx, `SELECT pr.id::text,pr.tenant_id::text,pr.subject_id::text,sp.public_id::text,pr.public_id::text,pr.status,pr.request_type FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id WHERE pr.id=$1::uuid`, id).Scan(&value.id, &value.tenantID, &value.subjectID, &value.subjectPublicID, &value.publicID, &value.status, &value.requestType)
|
||||
@@ -149,6 +175,16 @@ func (p *DeleteProcessor) loadSubtasks(ctx context.Context, requestID string) ([
|
||||
|
||||
func (p *DeleteProcessor) executeSubtask(ctx context.Context, request deletionRequest, subsystem string) (string, string, error) {
|
||||
switch subsystem {
|
||||
case "call_memory":
|
||||
var pending callMemoryDeletionPending
|
||||
err := p.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM memory_projection_job WHERE subject_id=$1::uuid AND state<>'VISIBLE'), EXISTS(SELECT 1 FROM call_session WHERE subject_id=$1::uuid AND manifest_status IN('ISSUED','REVOKED') AND NOT(local_audio_off AND sdk_stopped AND remote_state IN ('NOT_STARTED','STOPPED_OBSERVED'))), EXISTS(SELECT 1 FROM memory_deletion_ledger WHERE subject_id=$1::uuid AND backup_cleanup_verified_at IS NULL), EXISTS(SELECT 1 FROM memory_user_state WHERE subject_id=$1::uuid AND legacy_projection_pending), call_legacy_cleanup_pending($1::uuid)`, request.subjectID).Scan(&pending.Projection, &pending.Runtime, &pending.Backup, &pending.LegacyProjection, &pending.LegacyCleanup)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := pending.err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return "COMPLETED", "", nil
|
||||
case "redis":
|
||||
keys, err := p.subjectRedisKeys(ctx, request.tenantID, request.subjectID)
|
||||
if err != nil {
|
||||
@@ -257,6 +293,32 @@ func (p *DeleteProcessor) executeSubtask(ctx context.Context, request deletionRe
|
||||
}
|
||||
}
|
||||
|
||||
type callMemoryDeletionPending struct {
|
||||
Projection, Runtime, Backup, LegacyProjection, LegacyCleanup bool
|
||||
}
|
||||
|
||||
func (pending callMemoryDeletionPending) err() error {
|
||||
reasons := make([]string, 0, 5)
|
||||
for _, condition := range []struct {
|
||||
pending bool
|
||||
reason string
|
||||
}{
|
||||
{pending.Projection, "memory_projection"},
|
||||
{pending.Runtime, "runtime_stop_confirmation"},
|
||||
{pending.Backup, "backup_cleanup_evidence"},
|
||||
{pending.LegacyProjection, "legacy_projection"},
|
||||
{pending.LegacyCleanup, "legacy_cleanup_evidence"},
|
||||
} {
|
||||
if condition.pending {
|
||||
reasons = append(reasons, condition.reason)
|
||||
}
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return nil
|
||||
}
|
||||
return outbox.RetryAfter(fmt.Errorf("call memory deletion pending: %s", strings.Join(reasons, ", ")), time.Minute)
|
||||
}
|
||||
|
||||
func (p *DeleteProcessor) subjectRedisKeys(ctx context.Context, tenantID, subjectID string) ([]string, error) {
|
||||
rows, err := p.pool.Query(ctx, `SELECT DISTINCT c.public_id::text,d.public_id::text FROM conversation c JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid`, tenantID, subjectID)
|
||||
if err != nil {
|
||||
@@ -277,6 +339,13 @@ func (p *DeleteProcessor) subjectRedisKeys(ctx context.Context, tenantID, subjec
|
||||
func (p *DeleteProcessor) redactPostgreSQL(ctx context.Context, request deletionRequest) error {
|
||||
return database.Transact(ctx, p.pool, func(tx pgx.Tx) error {
|
||||
statements := []string{
|
||||
`DELETE FROM call_request_result WHERE response->>'manifest_b64' IN(SELECT manifest_b64 FROM call_session WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`,
|
||||
`UPDATE call_event SET payload='{"redacted":true}'::jsonb WHERE session_id IN(SELECT id FROM call_session WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`,
|
||||
`UPDATE call_utterance SET text_content='[deleted]' WHERE session_id IN(SELECT id FROM call_session WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`,
|
||||
`UPDATE call_text_fragment SET text_content='[deleted]' WHERE session_id IN(SELECT id FROM call_session WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`,
|
||||
`UPDATE call_session SET manifest_b64=NULL,manifest_mac=NULL,budget_snapshot=budget_snapshot-'story_context' WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE call_postprocess_job SET state='SUPERSEDED' WHERE session_id IN(SELECT id FROM call_session WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`,
|
||||
`UPDATE memory_mutation SET patch='{}',confirmation_hash=NULL,proposal_digest=NULL,confirmation_expires_at=NULL,state=CASE WHEN state IN('RECEIVED','NEEDS_CONFIRMATION') THEN 'CANCELLED' ELSE state END WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE subject_fact SET content='[已按隐私请求删除]',source_ref=NULL,confirmation_status='superseded',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE conversation_turn SET final_text='[已按隐私请求删除]',provider_ref=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE conversation_summary SET content='[已按隐私请求删除]',source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',last_error=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
@@ -286,9 +355,14 @@ func (p *DeleteProcessor) redactPostgreSQL(ctx context.Context, request deletion
|
||||
`UPDATE fact_candidate SET content='[已按隐私请求删除]',time_range='{}'::jsonb,source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE fact_candidate_revision fcr SET content='[已按隐私请求删除]',edit_reason=NULL FROM fact_candidate fc WHERE fcr.fact_candidate_id=fc.id AND fc.tenant_id=$1::uuid AND fc.subject_id=$2::uuid`,
|
||||
`UPDATE memory_version mv SET content='[已按隐私请求删除]',source_refs='[{"type":"privacy_tombstone"}]'::jsonb,change_reason=NULL,content_digest=encode(digest('[已按隐私请求删除]','sha256'),'hex') FROM curated_memory cm WHERE mv.memory_id=cm.id AND cm.tenant_id=$1::uuid AND cm.subject_id=$2::uuid`,
|
||||
`UPDATE curated_memory SET status='DELETED',valid_to=COALESCE(valid_to,now()),deleted_at=COALESCE(deleted_at,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE curated_memory SET status='DELETED',entity_key='',completion_basis=CASE WHEN completion_basis IS NOT NULL THEN '{"redacted":true}'::jsonb END,valid_to=COALESCE(valid_to,now()),deleted_at=COALESCE(deleted_at,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE memory_index_entry SET embedding=NULL,provider_ref=NULL,status='DELETED',last_error=NULL,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`DELETE FROM conversation_memory_use WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`DELETE FROM memoir_document WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`DELETE FROM chat_daily_event WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE memoir_import_turn SET content='[deleted]' WHERE import_id IN(SELECT id FROM memoir_chat_import WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`,
|
||||
`UPDATE memoir_chat_import SET title='[deleted]',status='FAILED' WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE memoir_generation_job SET state='FAILED',error_code='MEMOIR_CONSENT_REQUIRED',updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`DELETE FROM memory_tool_call WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE content_review_signal SET reason='[privacy-redacted]' WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
`UPDATE consent_snapshot SET effective_consents='{}'::jsonb,source_consent_ids='[]'::jsonb WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||
@@ -312,7 +386,7 @@ func (p *DeleteProcessor) redactPostgreSQL(ctx context.Context, request deletion
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE outbox_event SET payload='{"privacy_redacted":true}'::jsonb,status=CASE WHEN status IN ('pending','processing') THEN 'completed' ELSE status END,completed_at=CASE WHEN status IN ('pending','processing') THEN COALESCE(completed_at,now()) ELSE completed_at END,locked_at=NULL,locked_by=NULL,updated_at=now() WHERE tenant_id=$1::uuid AND aggregate_id IN (SELECT id FROM conversation WHERE subject_id=$2::uuid UNION SELECT id FROM curated_memory WHERE subject_id=$2::uuid UNION SELECT id FROM export_task WHERE subject_id=$2::uuid UNION SELECT ub.public_id FROM upload_batch ub JOIN conversation c ON c.id=ub.conversation_id WHERE c.subject_id=$2::uuid)`, request.tenantID, request.subjectID); err != nil {
|
||||
if _, err := tx.Exec(ctx, `UPDATE outbox_event SET payload='{"privacy_redacted":true}'::jsonb,status=CASE WHEN status IN ('pending','processing') THEN 'completed' ELSE status END,completed_at=CASE WHEN status IN ('pending','processing') THEN COALESCE(completed_at,now()) ELSE completed_at END,locked_at=NULL,locked_by=NULL,updated_at=now() WHERE tenant_id=$1::uuid AND aggregate_id IN (SELECT id FROM conversation WHERE subject_id=$2::uuid UNION SELECT id FROM call_session WHERE subject_id=$2::uuid UNION SELECT id FROM curated_memory WHERE subject_id=$2::uuid UNION SELECT id FROM export_task WHERE subject_id=$2::uuid UNION SELECT ub.public_id FROM upload_batch ub JOIN conversation c ON c.id=ub.conversation_id WHERE c.subject_id=$2::uuid)`, request.tenantID, request.subjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE audit_event SET purpose=NULL,detail='{"privacy_redacted":true}'::jsonb WHERE tenant_id=$1::uuid AND (detail->>'subject_id'=(SELECT public_id::text FROM subject_profile WHERE id=$2::uuid) OR resource_id IN (SELECT public_id FROM subject_profile WHERE id=$2::uuid UNION SELECT public_id FROM subject_fact WHERE subject_id=$2::uuid UNION SELECT public_id FROM consent_record WHERE subject_id=$2::uuid UNION SELECT public_id FROM conversation WHERE subject_id=$2::uuid UNION SELECT public_id FROM story_draft WHERE subject_id=$2::uuid UNION SELECT public_id FROM curated_memory WHERE subject_id=$2::uuid UNION SELECT public_id FROM export_task WHERE subject_id=$2::uuid UNION SELECT public_id FROM privacy_request WHERE subject_id=$2::uuid UNION SELECT public_id FROM recording_access_grant WHERE subject_id=$2::uuid UNION SELECT public_id FROM sharing_grant WHERE subject_id=$2::uuid UNION SELECT ub.public_id FROM upload_batch ub JOIN conversation c ON c.id=ub.conversation_id WHERE c.subject_id=$2::uuid UNION SELECT ms.public_id FROM media_segment ms JOIN conversation c ON c.id=ms.conversation_id WHERE c.subject_id=$2::uuid))`, request.tenantID, request.subjectID); err != nil {
|
||||
@@ -326,9 +400,11 @@ func safeDeletionError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
value := err.Error()
|
||||
// PostgreSQL requires valid UTF-8; slicing an error at a byte boundary can
|
||||
// split a Chinese character and prevent the failure receipt from being saved.
|
||||
value := []rune(err.Error())
|
||||
if len(value) > 450 {
|
||||
return value[:450]
|
||||
value = value[:450]
|
||||
}
|
||||
return value
|
||||
return string(value)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/cache"
|
||||
"data-terminal-cloud/internal/database"
|
||||
@@ -21,9 +23,13 @@ const (
|
||||
|
||||
type privacyDeleteStore struct {
|
||||
removed []string
|
||||
failure error
|
||||
}
|
||||
|
||||
func (s *privacyDeleteStore) Remove(_ context.Context, reference string) error {
|
||||
if s.failure != nil {
|
||||
return s.failure
|
||||
}
|
||||
s.removed = append(s.removed, reference)
|
||||
return nil
|
||||
}
|
||||
@@ -74,7 +80,7 @@ func TestDeleteProcessorReconcilesAllSubsystemsAndCreatesTombstone(t *testing.T)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO privacy_subtask (
|
||||
public_id,privacy_request_id,subsystem,target_type,target_ref
|
||||
) SELECT gen_random_uuid(),$1::uuid,subsystem,'subject_data',subsystem
|
||||
FROM unnest(ARRAY['redis','export_files','object_storage','pgvector','provider_index','postgresql']) AS subsystem`, privacyDeleteRequestID); err != nil {
|
||||
FROM unnest(ARRAY['redis','export_files','object_storage','pgvector','provider_index','postgresql','call_memory']) AS subsystem`, privacyDeleteRequestID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -85,6 +91,50 @@ func TestDeleteProcessorReconcilesAllSubsystemsAndCreatesTombstone(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.failure = outbox.RetryAfter(errors.New("provider deletion rate limited"), time.Minute)
|
||||
if err := processor.Handle(ctx, outbox.Event{AggregateID: privacyDeleteRequestID}); err == nil || !outbox.IsRetryable(err) || outbox.RetryDelay(err) != time.Minute {
|
||||
t.Fatalf("dependency failure lost retry metadata: %v", err)
|
||||
}
|
||||
var failureStatus, failedSubsystem, taskStatus, lastError string
|
||||
var nextAttempt time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT pr.status,pr.result_summary->>'failed_subsystem',ps.status,ps.last_error,ps.next_attempt_at FROM privacy_request pr JOIN privacy_subtask ps ON ps.privacy_request_id=pr.id AND ps.subsystem='export_files' WHERE pr.id=$1::uuid`, privacyDeleteRequestID).Scan(&failureStatus, &failedSubsystem, &taskStatus, &lastError, &nextAttempt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failureStatus != "PARTIAL_FAILED" || failedSubsystem != "export_files" || taskStatus != "FAILED" || lastError != "provider deletion rate limited" || time.Until(nextAttempt) < 55*time.Second {
|
||||
t.Fatalf("failure was hidden or cooldown lost: request=%s subsystem=%s task=%s error=%q next=%v", failureStatus, failedSubsystem, taskStatus, lastError, nextAttempt)
|
||||
}
|
||||
// A dependency without Retry-After must use the same minimum cooldown in
|
||||
// the durable receipt and the error returned to the outbox worker.
|
||||
store.failure = errors.New("temporary object removal failure")
|
||||
if err := processor.Handle(ctx, outbox.Event{AggregateID: privacyDeleteRequestID}); err == nil || outbox.RetryDelay(err) != 30*time.Second {
|
||||
t.Fatalf("ordinary dependency failure did not preserve the receipt cooldown: %v", err)
|
||||
}
|
||||
var redisAttempts, exportAttempts int
|
||||
if err := pool.QueryRow(ctx, `SELECT attempts FROM privacy_subtask WHERE privacy_request_id=$1::uuid AND subsystem='redis'`, privacyDeleteRequestID).Scan(&redisAttempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT attempts,next_attempt_at FROM privacy_subtask WHERE privacy_request_id=$1::uuid AND subsystem='export_files'`, privacyDeleteRequestID).Scan(&exportAttempts, &nextAttempt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redisAttempts != 1 || exportAttempts != 2 || time.Until(nextAttempt) < 25*time.Second {
|
||||
t.Fatalf("retry repeated completed work or advertised a different cooldown: redis=%d export=%d next=%v", redisAttempts, exportAttempts, nextAttempt)
|
||||
}
|
||||
store.failure = nil
|
||||
if _, err := pool.Exec(ctx, `UPDATE memory_user_state SET legacy_projection_pending=true WHERE subject_id=$1::uuid`, privacyDeleteSubjectID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := processor.Handle(ctx, outbox.Event{AggregateID: privacyDeleteRequestID}); err == nil || outbox.RetryDelay(err) != time.Minute {
|
||||
t.Fatalf("unfinished call memory cleanup was acknowledged: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT pr.status,pr.result_summary->>'failed_subsystem',ps.last_error FROM privacy_request pr JOIN privacy_subtask ps ON ps.privacy_request_id=pr.id AND ps.subsystem='call_memory' WHERE pr.id=$1::uuid`, privacyDeleteRequestID).Scan(&failureStatus, &failedSubsystem, &lastError); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failureStatus != "PARTIAL_FAILED" || failedSubsystem != "call_memory" || lastError != "call memory deletion pending: legacy_projection" {
|
||||
t.Fatalf("cleanup recovery reason was hidden: status=%s subsystem=%s reason=%q", failureStatus, failedSubsystem, lastError)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE memory_user_state SET legacy_projection_pending=false WHERE subject_id=$1::uuid`, privacyDeleteSubjectID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := processor.Handle(ctx, outbox.Event{AggregateID: privacyDeleteRequestID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -110,12 +160,18 @@ func TestDeleteProcessorReconcilesAllSubsystemsAndCreatesTombstone(t *testing.T)
|
||||
if err := pool.QueryRow(ctx, `SELECT status,object_ref FROM export_task WHERE id='90000000-0000-0000-0000-000000000745'::uuid`).Scan(&exportStatus, &exportReference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestStatus != "COMPLETED" || subjectStatus != "deleted" || preferredName != "[已删除]" || completed != 5 || skipped != 1 || tombstones != 1 || audits != 1 || len(store.removed) != 1 || store.removed[0] != "privacy-delete/export.zip" || exportStatus != "EXPIRED" || exportReference != nil {
|
||||
if requestStatus != "COMPLETED" || subjectStatus != "deleted" || preferredName != "[已删除]" || completed != 6 || skipped != 1 || tombstones != 1 || audits != 1 || len(store.removed) != 1 || store.removed[0] != "privacy-delete/export.zip" || exportStatus != "EXPIRED" || exportReference != nil {
|
||||
t.Fatalf("request=%s subject=%s name=%q completed=%d skipped=%d tombstones=%d audits=%d removed=%v", requestStatus, subjectStatus, preferredName, completed, skipped, tombstones, audits, store.removed)
|
||||
}
|
||||
if err := processor.Handle(ctx, outbox.Event{AggregateID: privacyDeleteRequestID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT attempts FROM privacy_subtask WHERE privacy_request_id=$1::uuid AND subsystem='export_files'`, privacyDeleteRequestID).Scan(&exportAttempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exportAttempts != 3 || len(store.removed) != 1 {
|
||||
t.Fatalf("completed deletion was executed again: attempts=%d removed=%d", exportAttempts, len(store.removed))
|
||||
}
|
||||
if err := processor.Handle(ctx, outbox.Event{AggregateID: "90000000-0000-0000-0000-000000000799"}); err != nil {
|
||||
t.Fatalf("missing deletion request error=%v", err)
|
||||
}
|
||||
@@ -128,6 +184,7 @@ func cleanupPrivacyDeleteFixture(t *testing.T, ctx context.Context, pool *pgxpoo
|
||||
`DELETE FROM audit_event WHERE action='privacy.delete.complete' AND resource_id='90000000-0000-0000-0000-000000000744'::uuid`,
|
||||
`DELETE FROM privacy_request WHERE id='90000000-0000-0000-0000-000000000743'::uuid`,
|
||||
`DELETE FROM export_task WHERE id='90000000-0000-0000-0000-000000000745'::uuid`,
|
||||
`DELETE FROM memory_user_state WHERE subject_id='90000000-0000-0000-0000-000000000741'::uuid`,
|
||||
`DELETE FROM subject_profile WHERE id='90000000-0000-0000-0000-000000000741'::uuid`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
|
||||
@@ -5,6 +5,10 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
)
|
||||
|
||||
func TestDeleteProcessorValidationAndSafeErrors(t *testing.T) {
|
||||
@@ -29,3 +33,63 @@ func TestDeleteProcessorValidationAndSafeErrors(t *testing.T) {
|
||||
t.Fatalf("truncated safe error length=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletionRetryCooldownMatchesReceipt(t *testing.T) {
|
||||
cause := errors.New("temporary deletion dependency failure")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
err error
|
||||
want time.Duration
|
||||
}{
|
||||
{"no supplier delay", outbox.Retryable(cause), 30 * time.Second},
|
||||
{"short supplier delay", outbox.RetryAfter(cause, time.Second), 30 * time.Second},
|
||||
{"long supplier delay", outbox.RetryAfter(cause, 2*time.Minute), 2 * time.Minute},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := deletionRetryError(test.err)
|
||||
if !outbox.IsRetryable(got) || outbox.RetryDelay(got) != test.want || !errors.Is(got, cause) {
|
||||
t.Fatalf("retry metadata or cause changed: %v delay=%v", got, outbox.RetryDelay(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := deletionRetryError(cause); got != cause || outbox.IsRetryable(got) {
|
||||
t.Fatalf("permanent failure became retryable: %v", got)
|
||||
}
|
||||
if got := deletionRetryError(nil); got != nil {
|
||||
t.Fatalf("nil failure became non-nil: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeDeletionErrorRetainsValidUTF8(t *testing.T) {
|
||||
// The former 450-byte slice cut the first Chinese character in this message.
|
||||
message := strings.Repeat("x", 449) + "删除依赖暂不可用"
|
||||
got := safeDeletionError(errors.New(message))
|
||||
if !utf8.ValidString(got) || utf8.RuneCountInString(got) != 450 || !strings.HasSuffix(got, "删") {
|
||||
t.Fatalf("failure receipt is not bounded valid text: bytes=%d runes=%d", len(got), utf8.RuneCountInString(got))
|
||||
}
|
||||
if got := safeDeletionError(errors.New("短错误")); got != "短错误" {
|
||||
t.Fatalf("short Unicode error changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallMemoryDeletionReportsOutstandingProofs(t *testing.T) {
|
||||
if err := (callMemoryDeletionPending{}).err(); err != nil {
|
||||
t.Fatalf("fully reconciled state was blocked: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
pending callMemoryDeletionPending
|
||||
want string
|
||||
}{
|
||||
{callMemoryDeletionPending{Projection: true}, "memory_projection"},
|
||||
{callMemoryDeletionPending{Runtime: true}, "runtime_stop_confirmation"},
|
||||
{callMemoryDeletionPending{Backup: true}, "backup_cleanup_evidence"},
|
||||
{callMemoryDeletionPending{LegacyProjection: true}, "legacy_projection"},
|
||||
{callMemoryDeletionPending{LegacyCleanup: true}, "legacy_cleanup_evidence"},
|
||||
{callMemoryDeletionPending{Runtime: true, Backup: true}, "runtime_stop_confirmation, backup_cleanup_evidence"},
|
||||
} {
|
||||
err := test.pending.err()
|
||||
if err == nil || err.Error() != "call memory deletion pending: "+test.want || !outbox.IsRetryable(err) || outbox.RetryDelay(err) != time.Minute {
|
||||
t.Fatalf("missing cleanup evidence was hidden or acknowledged: want=%q error=%v", test.want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user