387 lines
15 KiB
Go
387 lines
15 KiB
Go
package controlplane
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func TestAccountStoreIsolatesAccountsAndAppliesIdempotentBatches(t *testing.T) {
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
manager, err := OpenAccountStoreManager(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer manager.Close()
|
|
|
|
scopesA := []ReportingScope{
|
|
{ChatID: "chat-a", DataType: "read", ConfigVersion: 1},
|
|
}
|
|
accountA, err := manager.RegisterAccount(ctx, AccountRegistration{
|
|
AccountID: "account-a", StableIdentity: "wechat-a", SourceNodeID: "node-a", SourceGeneration: "generation-a", Verified: true, AuthorizationVersion: 1, ReportingScopes: scopesA,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer accountA.Close()
|
|
accountB, err := manager.RegisterAccount(ctx, AccountRegistration{
|
|
AccountID: "account-b", StableIdentity: "wechat-b", SourceNodeID: "node-b", SourceGeneration: "generation-b", Verified: true, AuthorizationVersion: 1,
|
|
ReportingScopes: []ReportingScope{{ChatID: "chat-b", DataType: "read", ConfigVersion: 1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer accountB.Close()
|
|
if accountA.Path() == accountB.Path() {
|
|
t.Fatal("accounts share a shard path")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "catalog.sqlite")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
batch := IngestBatch{
|
|
BatchID: "batch-1", SourceGeneration: "generation-a", StreamKey: "messages", Sequence: 1,
|
|
CursorStart: "0", CursorEnd: "1", PayloadHash: "hash-1", CoverageState: "complete",
|
|
Conversations: []ConversationRecord{{ChatID: "chat-a", ChatType: "private", Title: "A", Source: "db", ObservedAt: now, DirectoryState: "active"}},
|
|
Messages: []MessageRecord{{MessageID: "message-1", ChatID: "chat-a", SourceMessageID: "source-1", Direction: "incoming", MessageType: "text", Text: "hello", SourceTime: now, ObservedAt: now, SourceVersion: "wx-1", PayloadHash: "message-hash"}},
|
|
}
|
|
result, err := accountA.ApplyBatch(ctx, batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Duplicate || result.ConfirmedSequence != 1 {
|
|
t.Fatalf("unexpected first apply result: %+v", result)
|
|
}
|
|
duplicate, err := accountA.ApplyBatch(ctx, batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !duplicate.Duplicate || duplicate.ConfirmedSequence != 1 {
|
|
t.Fatalf("unexpected duplicate result: %+v", duplicate)
|
|
}
|
|
conflicting := batch
|
|
conflicting.PayloadHash = "different"
|
|
if _, err := accountA.ApplyBatch(ctx, conflicting); !errors.Is(err, ErrBatchConflict) {
|
|
t.Fatalf("expected batch conflict, got %v", err)
|
|
}
|
|
gap := batch
|
|
gap.BatchID = "batch-3"
|
|
gap.Sequence = 3
|
|
gap.PayloadHash = "hash-3"
|
|
if _, err := accountA.ApplyBatch(ctx, gap); !errors.Is(err, ErrBatchSequenceGap) {
|
|
t.Fatalf("expected sequence gap, got %v", err)
|
|
}
|
|
unauthorized := batch
|
|
unauthorized.BatchID = "batch-2"
|
|
unauthorized.Sequence = 2
|
|
unauthorized.PayloadHash = "hash-2"
|
|
unauthorized.Messages = []MessageRecord{{MessageID: "message-b", ChatID: "chat-b", SourceMessageID: "source-b", Direction: "incoming", MessageType: "text", Text: "no", SourceTime: now, ObservedAt: now, SourceVersion: "wx-1", PayloadHash: "message-b-hash"}}
|
|
if _, err := accountA.ApplyBatch(ctx, unauthorized); !errors.Is(err, ErrAccountNotAuthorized) {
|
|
t.Fatalf("expected unauthorized batch, got %v", err)
|
|
}
|
|
|
|
messages, err := accountA.QueryMessages(ctx, "chat-a", 20, 0)
|
|
if err != nil || len(messages) != 1 || messages[0].Text != "hello" {
|
|
t.Fatalf("unexpected stored messages: %v %+v", err, messages)
|
|
}
|
|
conversations, err := accountA.QueryConversations(ctx, 20, 0)
|
|
if err != nil || len(conversations) != 1 || conversations[0].ChatID != "chat-a" {
|
|
t.Fatalf("unexpected stored conversations: %v %+v", err, conversations)
|
|
}
|
|
}
|
|
|
|
func TestAccountStoreBackupRestoreAndSchemaFailureAreSafe(t *testing.T) {
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
manager, err := OpenAccountStoreManager(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := manager.RegisterAccount(ctx, AccountRegistration{
|
|
AccountID: "account-a", StableIdentity: "wechat-a", SourceNodeID: "node-a", SourceGeneration: "generation-a", Verified: true,
|
|
ReportingScopes: []ReportingScope{{ChatID: "chat-a", DataType: "messages", ConfigVersion: 1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().UTC()
|
|
batch1 := IngestBatch{
|
|
BatchID: "batch-1", SourceGeneration: "generation-a", StreamKey: "messages", Sequence: 1, CursorStart: "0", CursorEnd: "1", PayloadHash: "hash-1", CoverageState: "complete",
|
|
Messages: []MessageRecord{{MessageID: "message-1", ChatID: "chat-a", SourceMessageID: "source-1", Direction: "incoming", MessageType: "text", Text: "one", SourceTime: now, ObservedAt: now, SourceVersion: "wx", PayloadHash: "message-hash-1"}},
|
|
}
|
|
if _, err := store.ApplyBatch(ctx, batch1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backup := filepath.Join(root, "backups", "account-a.sqlite")
|
|
if err := store.Backup(ctx, backup); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.RestoreAccount(ctx, "account-a", backup); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restored, err := manager.OpenAccount(ctx, "account-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
batch2 := batch1
|
|
batch2.BatchID = "batch-2"
|
|
batch2.Sequence = 2
|
|
batch2.CursorStart = "1"
|
|
batch2.CursorEnd = "2"
|
|
batch2.PayloadHash = "hash-2"
|
|
batch2.Messages = []MessageRecord{{MessageID: "message-2", ChatID: "chat-a", SourceMessageID: "source-2", Direction: "incoming", MessageType: "text", Text: "two", SourceTime: now.Add(time.Second), ObservedAt: now.Add(time.Second), SourceVersion: "wx", PayloadHash: "message-hash-2"}}
|
|
if result, err := restored.ApplyBatch(ctx, batch2); err != nil || result.ConfirmedSequence != 2 {
|
|
t.Fatalf("cursor did not continue after restore: %+v %v", result, err)
|
|
}
|
|
if err := restored.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
corruptBackup := filepath.Join(root, "backups", "corrupt.sqlite")
|
|
if err := copyFile(backup, corruptBackup); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
corruptDB, err := sql.Open("sqlite", corruptBackup)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := corruptDB.Exec(`UPDATE schema_meta SET value = '999' WHERE key = 'schema_version'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := corruptDB.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.RestoreAccount(ctx, "account-a", corruptBackup); err == nil {
|
|
t.Fatal("expected unsupported schema version")
|
|
}
|
|
if _, err := manager.OpenAccount(ctx, "account-a"); err != nil {
|
|
t.Fatalf("original shard should remain usable after failed restore: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAccountStoreMaintenanceCapacityAndRevocation(t *testing.T) {
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
manager, err := OpenAccountStoreManager(root, AccountStoreManagerOptions{Retention: time.Hour, MaxBatchBytes: 1024})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer manager.Close()
|
|
store, err := manager.RegisterAccount(ctx, AccountRegistration{
|
|
AccountID: "account-a", StableIdentity: "wechat-a", SourceNodeID: "node-a", SourceGeneration: "generation-a", Verified: true,
|
|
ReportingScopes: []ReportingScope{{ChatID: "chat-a", DataType: "read", ConfigVersion: 1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Now().UTC().Add(-2 * time.Hour)
|
|
batch := IngestBatch{
|
|
BatchID: "old-batch", SourceGeneration: "generation-a", StreamKey: "messages", Sequence: 1, PayloadHash: "old-hash",
|
|
Conversations: []ConversationRecord{{ChatID: "chat-a", ChatType: "private", Title: "old", Source: "db", ObservedAt: old, DirectoryState: "active"}},
|
|
Messages: []MessageRecord{{MessageID: "old-message", ChatID: "chat-a", SourceMessageID: "old-source", Direction: "incoming", MessageType: "text", Text: "old", SourceTime: old, ObservedAt: old, SourceVersion: "wx", PayloadHash: "old-message-hash"}},
|
|
}
|
|
if _, err := store.ApplyBatch(ctx, batch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, err := manager.RunMaintenance(ctx, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.DeletedMessages != 1 || report.DeletedConversations != 1 || report.Checkpointed != 1 {
|
|
t.Fatalf("unexpected maintenance report: %+v", report)
|
|
}
|
|
if _, err := store.QueryMessages(ctx, "chat-a", 20, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.SetScopes(ctx, "account-a", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.QueryMessages(ctx, "chat-a", 20, 0); !errors.Is(err, ErrAccountNotAuthorized) {
|
|
t.Fatalf("expected revoked query to be rejected, got %v", err)
|
|
}
|
|
if err := manager.SetScopes(ctx, "account-a", []ReportingScope{{ChatID: "chat-a", DataType: "read", ConfigVersion: 2}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
oversized := batch
|
|
oversized.BatchID = "oversized"
|
|
oversized.Sequence = 2
|
|
oversized.PayloadHash = "oversized-hash"
|
|
oversized.Messages = []MessageRecord{{MessageID: "oversized-message", ChatID: "chat-a", SourceMessageID: "oversized-source", Direction: "incoming", MessageType: "text", Text: strings.Repeat("x", 2000), SourceTime: time.Now().UTC(), ObservedAt: time.Now().UTC(), SourceVersion: "wx", PayloadHash: "oversized-message-hash"}}
|
|
if _, err := store.ApplyBatch(ctx, oversized); !errors.Is(err, ErrAccountCapacityExceeded) {
|
|
t.Fatalf("expected oversized batch to be rejected by capacity budget, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAccountStoreSyntheticConcurrentReadWrite(t *testing.T) {
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
manager, err := OpenAccountStoreManager(root, AccountStoreManagerOptions{MaxShardBytes: 8 * 1024 * 1024, MaxBatchBytes: 256 * 1024})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer manager.Close()
|
|
const accountCount = 8
|
|
const batchesPerAccount = 5
|
|
const messagesPerBatch = 20
|
|
for accountIndex := 0; accountIndex < accountCount; accountIndex++ {
|
|
accountID := fmt.Sprintf("account-%02d", accountIndex)
|
|
chatID := fmt.Sprintf("chat-%02d", accountIndex)
|
|
store, err := manager.RegisterAccount(ctx, AccountRegistration{
|
|
AccountID: accountID, StableIdentity: accountID, SourceNodeID: "pressure-node", SourceGeneration: "pressure-generation", Verified: true,
|
|
ReportingScopes: []ReportingScope{{ChatID: chatID, DataType: "read", ConfigVersion: 1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for batchIndex := 0; batchIndex < batchesPerAccount; batchIndex++ {
|
|
messages := make([]MessageRecord, 0, messagesPerBatch)
|
|
for messageIndex := 0; messageIndex < messagesPerBatch; messageIndex++ {
|
|
messages = append(messages, MessageRecord{MessageID: fmt.Sprintf("%s-%d-%d", accountID, batchIndex, messageIndex), ChatID: chatID, SourceMessageID: fmt.Sprintf("source-%d-%d", batchIndex, messageIndex), Direction: "incoming", MessageType: "text", Text: "synthetic", SourceTime: time.Now().UTC(), ObservedAt: time.Now().UTC(), SourceVersion: "pressure", PayloadHash: fmt.Sprintf("hash-%d-%d", batchIndex, messageIndex)})
|
|
}
|
|
if _, err := store.ApplyBatch(ctx, IngestBatch{BatchID: fmt.Sprintf("%s-batch-%d", accountID, batchIndex), SourceGeneration: "pressure-generation", StreamKey: "messages", Sequence: int64(batchIndex + 1), PayloadHash: fmt.Sprintf("batch-hash-%s-%d", accountID, batchIndex), Messages: messages}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
start := time.Now()
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var slowest time.Duration
|
|
var rowsRead int
|
|
for accountIndex := 0; accountIndex < accountCount; accountIndex++ {
|
|
accountID := fmt.Sprintf("account-%02d", accountIndex)
|
|
chatID := fmt.Sprintf("chat-%02d", accountIndex)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
store, err := manager.OpenAccount(ctx, accountID)
|
|
if err != nil {
|
|
t.Errorf("open %s: %v", accountID, err)
|
|
return
|
|
}
|
|
defer store.Close()
|
|
localStart := time.Now()
|
|
for i := 0; i < 20; i++ {
|
|
items, err := store.QueryMessages(ctx, chatID, 100, 0)
|
|
if err != nil {
|
|
t.Errorf("query %s: %v", accountID, err)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
rowsRead += len(items)
|
|
mu.Unlock()
|
|
}
|
|
mu.Lock()
|
|
if elapsed := time.Since(localStart); elapsed > slowest {
|
|
slowest = elapsed
|
|
}
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
t.Logf("synthetic accounts=%d messages=%d reads=%d elapsed=%s slowest-account=%s", accountCount, accountCount*batchesPerAccount*messagesPerBatch, rowsRead, time.Since(start), slowest)
|
|
if rowsRead != accountCount*batchesPerAccount*messagesPerBatch*20 {
|
|
t.Fatalf("unexpected synthetic read count: %d", rowsRead)
|
|
}
|
|
}
|
|
|
|
func TestAccountStoreAddsIndexesToExistingShard(t *testing.T) {
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
manager, err := OpenAccountStoreManager(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := manager.RegisterAccount(ctx, AccountRegistration{AccountID: "account-a", StableIdentity: "wechat-a", SourceNodeID: "node-a", SourceGeneration: "generation-a", Verified: true, ReportingScopes: []ReportingScope{{ChatID: "chat-a", DataType: "read", ConfigVersion: 1}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `DROP INDEX idx_messages_observed_at; DROP INDEX idx_conversations_activity;`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager, err = OpenAccountStoreManager(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer manager.Close()
|
|
store, err = manager.OpenAccount(ctx, "account-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
rows, err := store.db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('idx_messages_observed_at', 'idx_conversations_activity') ORDER BY name`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var names []string
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(names, []string{"idx_conversations_activity", "idx_messages_observed_at"}) {
|
|
t.Fatalf("indexes were not restored on existing shard: %v", names)
|
|
}
|
|
}
|
|
|
|
func TestAccountStoreRejectsExpiredAuthorization(t *testing.T) {
|
|
ctx := context.Background()
|
|
manager, err := OpenAccountStoreManager(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer manager.Close()
|
|
expired := time.Now().UTC().Add(-time.Minute)
|
|
store, err := manager.RegisterAccount(ctx, AccountRegistration{
|
|
AccountID: "account-a", StableIdentity: "wechat-a", SourceNodeID: "node-a", SourceGeneration: "generation-a", Verified: true,
|
|
AuthorizationExpiresAt: &expired, ReportingScopes: []ReportingScope{{ChatID: "chat-a", DataType: "messages", ConfigVersion: 1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
_, err = store.ApplyBatch(ctx, IngestBatch{BatchID: "batch-1", SourceGeneration: "generation-a", StreamKey: "messages", Sequence: 1, PayloadHash: "hash"})
|
|
if !errors.Is(err, ErrAccountNotAuthorized) {
|
|
t.Fatalf("expected expired authorization error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func copyFile(source, target string) error {
|
|
data, err := os.ReadFile(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(target, data, 0o600)
|
|
}
|