Files
browser/platform/service/scum_query_ingest_test.go
T
npc0-hue 6985f77963 Keep the recurring SCUM database poll bounded
Each dispatched poll is a durable job with two job log streams, so an
unbounded poll history would grow the platform store by roughly two
thousand jobs a day per server. A new poll now retires older terminal jobs
of the same template together with their job log streams, leaving at most
two rows per template while nothing is in flight.
2026-09-16 18:08:18 +08:00

287 lines
14 KiB
Go

package service
import (
"errors"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func newSCUMQueryIngestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance, *time.Time) {
t.Helper()
clock := fixedTime
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return clock })
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{
ID: "server.scum",
Name: "SCUM",
Version: "1.0.0",
ServerType: "scum",
ManifestRef: "artifact://manifests/server.scum/1.0.0",
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
RequiredRunCapabilities: []string{capability},
DeclaredPermissions: []string{"server.game-client.read"},
Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true},
RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{capability}, Platforms: []string{"windows"}}},
TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{capability}}},
DataTargets: []domain.RuntimeDataTarget{{Key: "scum-database", Kind: "sqlite.snapshot", TransportKey: "scum-database", SourceRootKey: "server-root", SourcePath: "SCUM/Saved/SaveFiles/SCUM.db", WorkspaceKey: "databases/scum/SCUM.db", RefreshPolicy: "on-demand-snapshot", MaxBytes: 1 << 30, Platforms: []string{"windows"}}},
},
GameClientBridge: domain.GameClientBridgeManifest{Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{
Key: "scum.database.players", Title: "Read SCUM players", Permission: "server.game-client.read", Engine: "sqlite",
TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/queries/players.parameters.schema.json",
ResultSchemaRef: "schemas/queries/players.result.schema.json", SQLRef: "sql/sum-db-v57/players.sql",
PollIntervalSeconds: 60, MaxRows: 500, TimeoutSeconds: 30,
Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{
Collection: scumProjectionUsers, RowPath: "rows", UpsertKeys: []string{"steamId"},
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName", "loginIp": "lastLoginIp", "online": "online", "login": "freshLogin", "loginObservedAt": "lastLoginTime", "bankBalance": "normalBalance", "goldBars": "goldBalance", "x": "x", "y": "y", "z": "z"},
}},
}, {
Key: "scum.database.vehicles", Title: "Read SCUM vehicles", Permission: "server.game-client.read", Engine: "sqlite",
TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/queries/vehicles.parameters.schema.json",
ResultSchemaRef: "schemas/queries/vehicles.result.schema.json", SQLRef: "sql/scum-db-v57/vehicles.sql",
PollIntervalSeconds: 120, MaxRows: 500, TimeoutSeconds: 30,
Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{
Collection: scumProjectionVehicles, RowPath: "rows", UpsertKeys: []string{"gameVehicleId"},
FieldMappings: map[string]string{"gameVehicleId": "gameVehicleId", "vehicleClass": "vehicleClass", "displayName": "displayName", "exists": "existsInGame", "x": "x", "y": "y", "z": "z"},
}},
}}},
})
if err != nil {
t.Fatalf("create SCUM plugin: %v", err)
}
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("create run endpoint: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-scum-owner", DisplayName: "SCUM Owner", Email: "scum-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-scum-query", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Query Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server instance: %v", err)
}
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "run-local", Bindings: map[string]string{}}, true)
if err != nil {
t.Fatalf("build runtime binding: %v", err)
}
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
t.Fatalf("store runtime binding: %v", err)
}
hello := validRunControlHello()
hello.CapabilityReport.Capabilities = []string{capability}
hello.CapabilityReport.Fingerprint = "cap-scum-query"
if _, err := svc.RegisterRunHello(hello); err != nil {
t.Fatalf("register run hello: %v", err)
}
return svc, session, instance, &clock
}
func refreshSCUMQueryRunHeartbeat(t *testing.T, svc *CoreService, stamp time.Time) {
t.Helper()
endpoint, err := svc.store.RunEndpoints().Get("run-local")
if err != nil {
t.Fatalf("get run endpoint: %v", err)
}
endpoint.LastHeartbeatAt = stamp
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("refresh run endpoint: %v", err)
}
}
func countSCUMQueryJobs(t *testing.T, svc *CoreService, instanceID string) []domain.Job {
t.Helper()
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instanceID})
if err != nil {
t.Fatalf("list jobs: %v", err)
}
return jobs
}
func TestSCUMQueryTemplatesDispatchWithinPollInterval(t *testing.T) {
svc, _, instance, clock := newSCUMQueryIngestFixture(t)
aligned := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)
*clock = aligned
refreshSCUMQueryRunHeartbeat(t, svc, aligned)
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
t.Fatalf("reconcile query templates: %v", err)
}
jobs := countSCUMQueryJobs(t, svc, instance.ID)
if len(jobs) != 2 {
t.Fatalf("expected one job per declared template, got %d", len(jobs))
}
for _, job := range jobs {
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.TargetKey != "scum-database" {
t.Fatalf("unexpected dispatch job: %+v", job)
}
if job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.RemoteAdapterKind != string(domain.RemoteAdapterDatabase) {
t.Fatalf("unexpected dispatch scope: %+v", job.ExecutionInput)
}
if job.ExecutionInput.Inputs["sqlRef"] == "" || job.ExecutionInput.Inputs["templateKey"] == "" || job.ExecutionInput.Inputs["maxRows"] != "500" {
t.Fatalf("unexpected dispatch inputs: %+v", job.ExecutionInput.Inputs)
}
}
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
t.Fatalf("repeat reconcile: %v", err)
}
if repeated := countSCUMQueryJobs(t, svc, instance.ID); len(repeated) != 2 {
t.Fatalf("expected dispatch de-duplication inside one poll interval, got %d jobs", len(repeated))
}
*clock = aligned.Add(61 * time.Second)
refreshSCUMQueryRunHeartbeat(t, svc, *clock)
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
t.Fatalf("reconcile after player poll interval: %v", err)
}
if afterPlayers := countSCUMQueryJobs(t, svc, instance.ID); len(afterPlayers) != 3 {
t.Fatalf("expected only the players template to be due after 61s, got %d jobs", len(afterPlayers))
}
*clock = aligned.Add(301 * time.Second)
refreshSCUMQueryRunHeartbeat(t, svc, *clock)
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
t.Fatalf("reconcile after both poll intervals: %v", err)
}
if afterBoth := countSCUMQueryJobs(t, svc, instance.ID); len(afterBoth) != 5 {
t.Fatalf("expected both templates to be due after 301s, got %d jobs", len(afterBoth))
}
*clock = aligned.Add(4000 * time.Second)
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
t.Fatalf("reconcile with stale heartbeat: %v", err)
}
if staleJobs := countSCUMQueryJobs(t, svc, instance.ID); len(staleJobs) != 5 {
t.Fatalf("expected no dispatch while the run heartbeat is stale, got %d jobs", len(staleJobs))
}
}
func TestSCUMQueryProjectionFillsTypedTables(t *testing.T) {
svc, session, instance, _ := newSCUMQueryIngestFixture(t)
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
t.Fatalf("reconcile query templates: %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
t.Fatalf("list jobs: %v", err)
}
playerJob, vehicleJob := jobs[0], jobs[1]
if playerJob.ExecutionInput.Inputs["templateKey"] != "scum.database.players" {
playerJob, vehicleJob = vehicleJob, playerJob
}
playerJob.State = domain.JobStateSucceeded
playerJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"steamId":"76561199510658111","displayName":"love_fitting","lastLoginIp":"218.29.163.205","squadId":"38","online":1,"freshLogin":0,"normalBalance":100000,"goldBalance":7,"lastLoginTime":"2026-09-16T08:00:00.000Z","lastSaveTime":"2026-09-16T08:05:00Z","x":182046.75,"y":573985.875,"z":100962.578}]}`}
if err := svc.projectRemoteAdapterJobResult(playerJob, fixedTime); err != nil {
t.Fatalf("project player rows: %v", err)
}
vehicleJob.State = domain.JobStateSucceeded
vehicleJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"gameVehicleId":"5923426","vehicleClass":"MountainBike_ES","displayName":"","existsInGame":1,"x":-406400.84375,"y":558707.4375,"z":83294.6171875,"observedAt":"2026-09-16T08:05:00Z"}]}`}
if err := svc.projectRemoteAdapterJobResult(vehicleJob, fixedTime); err != nil {
t.Fatalf("project vehicle rows: %v", err)
}
users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID})
if err != nil || len(users) != 1 {
t.Fatalf("list users: users=%+v err=%v", users, err)
}
user := users[0]
if user.SteamID != "76561199510658111" || user.DisplayName != "love_fitting" || !user.Online || user.X == nil || *user.X != 182046.75 {
t.Fatalf("unexpected projected user: %+v", user)
}
if user.BankBalance == nil || *user.BankBalance != 100000 || user.GoldBars == nil || *user.GoldBars != 7 || user.SquadID != 38 {
t.Fatalf("unexpected projected economy: %+v", user)
}
if user.LastLoginAt.IsZero() || !user.LastLoginAt.Equal(time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)) {
t.Fatalf("expected last login from database evidence, got %s", user.LastLoginAt)
}
vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID})
if err != nil || len(vehicles) != 1 || vehicles[0].VehicleClass != "MountainBike_ES" || vehicles[0].X == nil || *vehicles[0].X != -406400.84375 {
t.Fatalf("unexpected projected vehicle: vehicles=%+v err=%v", vehicles, err)
}
userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
if err != nil || len(userTracks) != 1 {
t.Fatalf("expected one projected user trajectory, rows=%+v err=%v", userTracks, err)
}
vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID})
if err != nil || len(vehicleTracks) != 1 {
t.Fatalf("expected one projected vehicle trajectory, rows=%+v err=%v", vehicleTracks, err)
}
}
func TestSCUMQueryProjectionIgnoresUndeclaredTemplateResults(t *testing.T) {
svc, session, instance, _ := newSCUMQueryIngestFixture(t)
job := domain.Job{
ID: "job-unrelated", ServerInstanceID: instance.ID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, State: domain.JobStateSucceeded,
ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": "scum.database.unknown"}},
ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"steamId":"76561199510658111"}]}`},
}
if err := svc.projectRemoteAdapterJobResult(job, fixedTime); err != nil {
t.Fatalf("project undeclared template: %v", err)
}
users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID})
if err != nil || len(users) != 0 {
t.Fatalf("expected no projected users for an undeclared template, users=%+v err=%v", users, err)
}
}
func TestSCUMQueryJobRetentionKeepsBoundedHistory(t *testing.T) {
svc, _, instance, clock := newSCUMQueryIngestFixture(t)
aligned := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)
*clock = aligned
refreshSCUMQueryRunHeartbeat(t, svc, aligned)
playersJobs := func() []domain.Job {
jobs := countSCUMQueryJobs(t, svc, instance.ID)
players := make([]domain.Job, 0, len(jobs))
for _, job := range jobs {
if job.ExecutionInput.Inputs["templateKey"] == "scum.database.players" {
players = append(players, job)
}
}
return players
}
var firstJob domain.Job
for cycle := 0; cycle < 4; cycle++ {
if cycle > 0 {
*clock = aligned.Add(time.Duration(cycle) * 61 * time.Second)
refreshSCUMQueryRunHeartbeat(t, svc, *clock)
}
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
t.Fatalf("reconcile cycle %d: %v", cycle, err)
}
for _, job := range playersJobs() {
if isTerminalJobState(job.State) {
continue
}
if cycle == 0 {
firstJob = job
}
job.State = domain.JobStateSucceeded
job.TerminalAt = *clock
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("complete query job: %v", err)
}
}
}
retained := playersJobs()
if len(retained) != scumQueryJobRetention {
t.Fatalf("expected %d retained player query jobs, got %d", scumQueryJobRetention, len(retained))
}
for _, job := range retained {
if !isTerminalJobState(job.State) {
t.Fatalf("retention must not remove in-flight jobs: %+v", job)
}
}
if _, err := svc.store.Jobs().Get(firstJob.ID); !errors.Is(err, repo.ErrNotFound) {
t.Fatalf("expected the oldest query job to be retired, err=%v", err)
}
if _, err := svc.store.LogStreams().Get(jobLogStreamID(firstJob.ID, "stdout")); !errors.Is(err, repo.ErrNotFound) {
t.Fatalf("expected retired query job log streams to be removed, err=%v", err)
}
}