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.
287 lines
14 KiB
Go
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)
|
|
}
|
|
}
|