Files
browser/platform/service/scum_query_ingest.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

569 lines
18 KiB
Go

package service
import (
"encoding/json"
"errors"
"fmt"
"log"
"math"
"sort"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
// Plugin-declared SCUM database projection collections. The plugin owns the SQL
// text and the row-to-field mapping; the platform owns the typed tables those
// rows are projected into.
const (
scumProjectionUsers = "scum.users"
scumProjectionVehicles = "scum.vehicles"
)
const scumQueryTemplateIdempotencyPrefix = "scum-query:"
// Steady-state bounds for the recurring database poll. Every dispatched poll
// creates one durable job, so a new poll retires older terminal jobs of the
// same template together with their job log streams instead of leaving an
// unbounded history behind.
const (
scumQueryJobRetention = 2
scumQueryJobScanLimit = 64
)
// ReconcileSCUMQueryTemplatesForRunEndpoint dispatches due SCUM database read
// templates for every live server instance registered to one run endpoint.
func (svc *CoreService) ReconcileSCUMQueryTemplatesForRunEndpoint(runEndpointID string) error {
runEndpointID = strings.TrimSpace(runEndpointID)
if runEndpointID == "" {
return nil
}
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: runEndpointID})
if err != nil {
return err
}
for _, instance := range instances {
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
return err
}
}
return nil
}
// ReconcileSCUMQueryTemplates queues the plugin-declared SCUM database read
// templates that are due for one server instance.
//
// Call-count budget for one call: one server instance read, one plugin read, one
// run endpoint read, one in-memory dispatch lookup per declared template, and at
// most one job insert per declared template per poll interval. Job inserts are
// de-duplicated by run endpoint plus idempotency key, so repeated page loads and
// run heartbeats inside the same poll interval add no storage work.
func (svc *CoreService) ReconcileSCUMQueryTemplates(serverInstanceID string) error {
serverInstanceID = strings.TrimSpace(serverInstanceID)
if serverInstanceID == "" {
return nil
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
if instance.State == domain.ServerInstanceStateDeleted || strings.TrimSpace(instance.RunEndpointID) == "" {
return nil
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
templates := scumIngestQueryTemplates(plugin)
if len(templates) == 0 {
return nil
}
endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID)
if err != nil {
return nil
}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range plugin.RuntimeProfiles.TransportProfiles {
transports[transport.Key] = transport
}
stamp := svc.now()
for _, template := range templates {
transport, ok := transports[template.TransportKey]
if !ok || transport.Kind != "sqlite" || transport.TargetKey != template.TargetKey {
continue
}
if !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
continue
}
if !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
continue
}
if !svc.endpointSupports(endpoint, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
continue
}
interval := int64(template.PollIntervalSeconds)
if interval <= 0 {
continue
}
idempotencyKey := fmt.Sprintf("%s%s:%d", scumQueryTemplateIdempotencyPrefix, template.Key, stamp.Unix()/interval)
job := domain.Job{
ID: jobIDFromParts("job-scum-query", instance.ID, idempotencyKey),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
TargetKey: template.TargetKey,
InputRef: fmt.Sprintf("input://scum-queries/%s/%s", instance.ID, template.Key),
IdempotencyKey: idempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "SCUM database read queued"},
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 2, MaxBackoffSeconds: 10},
ExecutionInput: domain.JobExecutionInput{
WorkspaceScope: svc.runtimeProfileScope(instance.ID),
RemoteAdapterKey: transport.Key,
RemoteAdapterKind: string(domain.RemoteAdapterDatabase),
TimeoutSeconds: scumQueryTemplateTimeout(template),
PluginID: plugin.ID,
Inputs: map[string]string{
"templateKey": template.Key,
"sqlRef": template.SQLRef,
"maxRows": strconv.Itoa(scumQueryTemplateMaxRows(template)),
},
},
}
created, err := svc.CreateJob(job)
if err != nil {
return err
}
svc.pruneSCUMQueryJobs(instance.ID, job.InputRef, created.ID)
}
return nil
}
// pruneSCUMQueryJobs keeps the newest terminal jobs for one dispatched template
// and removes the rest. It runs once per poll interval, so the caller pays one
// bounded job read per dispatch and no work at all while a poll is in flight.
func (svc *CoreService) pruneSCUMQueryJobs(serverInstanceID string, inputRef string, keepJobID string) {
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID, Limit: scumQueryJobScanLimit})
if err != nil {
log.Printf("SCUM query job retention skipped server=%s error=%s", serverInstanceID, err.Error())
return
}
expired := make([]domain.Job, 0, len(jobs))
for _, candidate := range jobs {
if candidate.ID == keepJobID || candidate.InputRef != inputRef || !isTerminalJobState(candidate.State) {
continue
}
expired = append(expired, candidate)
}
retained := scumQueryJobRetention - 1
if retained < 0 {
retained = 0
}
if len(expired) <= retained {
return
}
sort.SliceStable(expired, func(i, j int) bool { return expired[i].CreatedAt.After(expired[j].CreatedAt) })
for _, job := range expired[retained:] {
if err := svc.deleteJobWithLogStreams(job); err != nil {
log.Printf("SCUM query job retention failed server=%s job=%s error=%s", serverInstanceID, job.ID, err.Error())
return
}
}
}
func (svc *CoreService) deleteJobWithLogStreams(job domain.Job) error {
for _, streamKey := range []string{"stdout", "stderr"} {
err := svc.store.LogStreams().Delete(jobLogStreamID(job.ID, streamKey))
if err != nil && !errors.Is(err, repo.ErrNotFound) {
return err
}
}
err := svc.store.Jobs().Delete(job.ID)
if err != nil && !errors.Is(err, repo.ErrNotFound) {
return err
}
return nil
}
func scumIngestQueryTemplates(plugin domain.GamePlugin) []domain.GameClientBridgeQueryTemplateDeclaration {
result := make([]domain.GameClientBridgeQueryTemplateDeclaration, 0, len(plugin.GameClientBridge.QueryTemplates))
for _, template := range plugin.GameClientBridge.QueryTemplates {
if template.PollIntervalSeconds <= 0 || len(template.Projections) == 0 {
continue
}
if strings.TrimSpace(template.SQLRef) == "" {
continue
}
supported := false
for _, projection := range template.Projections {
if scumProjectionCollectionSupported(projection.Collection) {
supported = true
break
}
}
if !supported {
continue
}
result = append(result, template)
}
return result
}
func scumProjectionCollectionSupported(collection string) bool {
switch strings.TrimSpace(collection) {
case scumProjectionUsers, scumProjectionVehicles:
return true
default:
return false
}
}
func scumQueryTemplateTimeout(template domain.GameClientBridgeQueryTemplateDeclaration) int {
if template.TimeoutSeconds > 0 {
return template.TimeoutSeconds
}
return 15
}
func scumQueryTemplateMaxRows(template domain.GameClientBridgeQueryTemplateDeclaration) int {
if template.MaxRows > 0 {
return template.MaxRows
}
return domain.SCUMDefaultListLimit
}
// projectSCUMQueryTemplateResult maps one completed database read into the typed
// SCUM tables through the projection declarations of the owning plugin.
func (svc *CoreService) projectSCUMQueryTemplateResult(job domain.Job, stamp time.Time) error {
templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"])
if templateKey == "" || job.ExecutionResult.Kind != "sqlite.query" {
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
template, ok := scumQueryTemplateByKey(plugin.GameClientBridge.QueryTemplates, templateKey)
if !ok {
return nil
}
rows, err := decodeSCUMQueryRows(job.ExecutionResult.Content)
if err != nil {
log.Printf("SCUM query projection skipped job=%s server=%s template=%s reason=rows_unreadable", job.ID, instance.ID, templateKey)
return nil
}
batch := domain.SCUMFactIngest{ServerInstanceID: instance.ID}
for _, projection := range template.Projections {
switch strings.TrimSpace(projection.Collection) {
case scumProjectionUsers:
batch.Users = append(batch.Users, scumUserFactsFromRows(rows, projection)...)
case scumProjectionVehicles:
batch.Vehicles = append(batch.Vehicles, scumVehicleFactsFromRows(rows, projection)...)
}
}
if len(batch.Users) > domain.SCUMFactBatchLimit {
batch.Users = batch.Users[:domain.SCUMFactBatchLimit]
}
if len(batch.Vehicles) > domain.SCUMFactBatchLimit {
batch.Vehicles = batch.Vehicles[:domain.SCUMFactBatchLimit]
}
if len(batch.Users) == 0 && len(batch.Vehicles) == 0 {
return nil
}
result, err := svc.ingestSCUMFactsForInstance(instance, batch, stamp)
if err != nil {
return err
}
log.Printf("SCUM query projection applied job=%s server=%s template=%s users=%d vehicles=%d accepted=%d/%d", job.ID, instance.ID, templateKey, len(batch.Users), len(batch.Vehicles), result.AcceptedUserCount, result.AcceptedVehicleCount)
return nil
}
func scumQueryTemplateByKey(templates []domain.GameClientBridgeQueryTemplateDeclaration, key string) (domain.GameClientBridgeQueryTemplateDeclaration, bool) {
for _, template := range templates {
if template.Key == key {
return template, true
}
}
return domain.GameClientBridgeQueryTemplateDeclaration{}, false
}
type scumQueryRow map[string]any
func decodeSCUMQueryRows(content string) ([]scumQueryRow, error) {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return nil, errors.New("query result is empty")
}
var payload struct {
Rows []map[string]any `json:"rows"`
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, err
}
rows := make([]scumQueryRow, 0, len(payload.Rows))
for _, row := range payload.Rows {
if len(row) == 0 {
continue
}
rows = append(rows, scumQueryRow(row))
}
return rows, nil
}
func scumRowField(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (any, bool) {
if source, ok := projection.FieldMappings[field]; ok {
value, exists := row[source]
if !exists {
return nil, false
}
return value, true
}
if value, ok := projection.FixedValues[field]; ok {
return value, true
}
value, exists := row[field]
return value, exists
}
func scumRowText(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) string {
value, ok := scumRowField(row, projection, field)
if !ok || value == nil {
return ""
}
return strings.TrimSpace(scumScalarText(value))
}
func scumScalarText(value any) string {
switch typed := value.(type) {
case string:
return typed
case bool:
if typed {
return "true"
}
return "false"
case float64:
if typed == math.Trunc(typed) && math.Abs(typed) < 1e15 {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
case int64:
return strconv.FormatInt(typed, 10)
case json.Number:
return typed.String()
default:
return fmt.Sprintf("%v", typed)
}
}
func scumRowBool(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (bool, bool) {
value, ok := scumRowField(row, projection, field)
if !ok || value == nil {
return false, false
}
switch typed := value.(type) {
case bool:
return typed, true
case float64:
return typed != 0, true
case int64:
return typed != 0, true
default:
text := strings.ToLower(strings.TrimSpace(scumScalarText(typed)))
switch text {
case "1", "true", "yes", "online", "active", "connected":
return true, true
case "0", "false", "no", "offline", "":
return false, true
default:
return false, false
}
}
}
func scumRowFloat(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (float64, bool) {
value, ok := scumRowField(row, projection, field)
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case int64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
return parsed, true
default:
parsed, err := strconv.ParseFloat(strings.TrimSpace(scumScalarText(typed)), 64)
if err != nil {
return 0, false
}
return parsed, true
}
}
func scumRowInt64(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (int64, bool) {
value, ok := scumRowField(row, projection, field)
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return int64(math.Round(typed)), true
case int64:
return typed, true
case json.Number:
parsed, err := typed.Int64()
if err != nil {
return 0, false
}
return parsed, true
default:
parsed, err := strconv.ParseInt(strings.TrimSpace(scumScalarText(typed)), 10, 64)
if err != nil {
return 0, false
}
return parsed, true
}
}
func scumRowTime(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration, field string) (time.Time, bool) {
value, ok := scumRowField(row, projection, field)
if !ok || value == nil {
return time.Time{}, false
}
switch typed := value.(type) {
case float64:
seconds, fraction := math.Modf(typed)
return time.Unix(int64(seconds), int64(fraction*float64(time.Second))).UTC(), true
case int64:
return time.Unix(typed, 0).UTC(), true
default:
text := strings.TrimSpace(scumScalarText(typed))
if text == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.000Z", "2006-01-02 15:04:05"} {
if parsed, err := time.Parse(layout, text); err == nil {
return parsed.UTC(), true
}
}
if seconds, err := strconv.ParseInt(text, 10, 64); err == nil {
return time.Unix(seconds, 0).UTC(), true
}
return time.Time{}, false
}
}
func scumUserFactsFromRows(rows []scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration) []domain.SCUMUserFact {
facts := make([]domain.SCUMUserFact, 0, len(rows))
for _, row := range rows {
steamID := scumRowText(row, projection, "steamId")
if steamID == "" {
continue
}
fact := domain.SCUMUserFact{
SteamID: steamID,
DisplayName: scumRowText(row, projection, "displayName"),
LoginIP: scumRowText(row, projection, "loginIp"),
RiddenGameVehicleID: scumRowText(row, projection, "riddenGameVehicleId"),
}
if online, ok := scumRowBool(row, projection, "online"); ok {
fact.Online = online
}
if login, ok := scumRowBool(row, projection, "login"); ok {
fact.Login = login
}
if observedAt, ok := scumRowTime(row, projection, "observedAt"); ok {
fact.ObservedAt = observedAt
}
if loginAt, ok := scumRowTime(row, projection, "loginObservedAt"); ok {
fact.LoginObservedAt = loginAt
}
if squadID, ok := scumRowInt64(row, projection, "squadId"); ok {
value := squadID
fact.SquadID = &value
}
if balance, ok := scumRowInt64(row, projection, "bankBalance"); ok {
value := balance
fact.BankBalance = &value
}
if gold, ok := scumRowInt64(row, projection, "goldBars"); ok {
value := gold
fact.GoldBars = &value
}
if position, ok := scumRowPosition(row, projection); ok {
fact.Position = position
}
facts = append(facts, fact)
}
return facts
}
func scumVehicleFactsFromRows(rows []scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration) []domain.SCUMVehicleFact {
facts := make([]domain.SCUMVehicleFact, 0, len(rows))
for _, row := range rows {
gameVehicleID := scumRowText(row, projection, "gameVehicleId")
if gameVehicleID == "" {
continue
}
fact := domain.SCUMVehicleFact{
GameVehicleID: gameVehicleID,
VehicleClass: scumRowText(row, projection, "vehicleClass"),
DisplayName: scumRowText(row, projection, "displayName"),
LockedBySteamID: scumRowText(row, projection, "lockedBySteamId"),
}
if exists, ok := scumRowBool(row, projection, "exists"); ok {
value := exists
fact.Exists = &value
}
if locked, ok := scumRowBool(row, projection, "locked"); ok {
value := locked
fact.Locked = &value
}
if lockedAt, ok := scumRowTime(row, projection, "lockedAt"); ok {
fact.LockedAt = lockedAt
}
if observedAt, ok := scumRowTime(row, projection, "observedAt"); ok {
fact.ObservedAt = observedAt
}
if position, ok := scumRowPosition(row, projection); ok {
fact.Position = position
}
facts = append(facts, fact)
}
return facts
}
func scumRowPosition(row scumQueryRow, projection domain.GameClientBridgeQueryProjectionDeclaration) (*domain.SCUMPosition, bool) {
x, hasX := scumRowFloat(row, projection, "x")
y, hasY := scumRowFloat(row, projection, "y")
z, hasZ := scumRowFloat(row, projection, "z")
if !hasX || !hasY || !hasZ {
return nil, false
}
return &domain.SCUMPosition{X: x, Y: y, Z: z}, true
}