Rebuild SCUM plugin-owned data flow
This commit is contained in:
@@ -40,6 +40,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
if err := svc.scheduleDuePluginQueries(claim.RunEndpointID, claim.Capabilities, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
lock := svc.logIngestLock(batch.ServerInstanceID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
lock.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
stamp := svc.now()
|
||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
@@ -44,6 +49,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) {
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedLogEntries(batch.Entries)); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
@@ -76,6 +86,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedBatch.Entries); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
svc.publishLogEvents(stream, storedBatch.Entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
@@ -87,6 +102,13 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}, nil
|
||||
}
|
||||
|
||||
func storedLogEntries(entries []domain.LogEntry) []domain.LogEntry {
|
||||
stored := domain.CopyLogEntries(entries)
|
||||
batch := domain.LogBatchIngest{Entries: stored}
|
||||
sanitizeLogNetworkFields(&batch)
|
||||
return batch.Entries
|
||||
}
|
||||
|
||||
func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
|
||||
jobID, ok := jobIDFromLogBatch(batch)
|
||||
if !ok {
|
||||
|
||||
@@ -2,10 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
type pluginDataQueryResult struct {
|
||||
@@ -42,7 +45,7 @@ func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error {
|
||||
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &result); err != nil {
|
||||
return validationError("declared query result is not valid JSON")
|
||||
}
|
||||
mutations := make([]domain.PluginDataMutation, 0, len(result.Rows))
|
||||
mutationsByKey := make(map[string]domain.PluginDataMutation, len(result.Rows))
|
||||
for _, row := range result.Rows {
|
||||
value := make(map[string]any, len(template.RowTarget.ColumnMappings))
|
||||
for destination, source := range template.RowTarget.ColumnMappings {
|
||||
@@ -52,15 +55,54 @@ func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutations = append(mutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value})
|
||||
if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeMerge {
|
||||
existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, template.RowTarget.Collection, key))
|
||||
if getErr == nil {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
} else if !errors.Is(getErr, repo.ErrNotFound) {
|
||||
return getErr
|
||||
}
|
||||
}
|
||||
mutationsByKey[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}
|
||||
}
|
||||
if len(mutations) == 0 {
|
||||
if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeReplace {
|
||||
existing, listErr := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection})
|
||||
if listErr != nil {
|
||||
return listErr
|
||||
}
|
||||
for _, record := range existing {
|
||||
if _, present := mutationsByKey[record.Key]; !present {
|
||||
mutationsByKey[record.Key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationDelete, Key: record.Key}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(mutationsByKey) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(mutationsByKey))
|
||||
for key := range mutationsByKey {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
mutations := make([]domain.PluginDataMutation, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
mutations = append(mutations, mutationsByKey[key])
|
||||
}
|
||||
_, err = svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection, Mutations: mutations})
|
||||
return err
|
||||
}
|
||||
|
||||
func mergePluginDataValues(existing, incoming map[string]any) map[string]any {
|
||||
merged := domain.CopyGameClientBridgePayload(existing)
|
||||
if merged == nil {
|
||||
merged = make(map[string]any, len(incoming))
|
||||
}
|
||||
for key, value := range incoming {
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func pluginDataRowKey(value map[string]any, keys []string) (string, error) {
|
||||
parts := make([]string, len(keys))
|
||||
for index, key := range keys {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func (svc *CoreService) scheduleDuePluginQueries(runEndpointID string, capabilities []string, stamp time.Time) error {
|
||||
if !containsString(capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return nil
|
||||
}
|
||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: runEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: runEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance.State == domain.ServerInstanceStateDeleted {
|
||||
continue
|
||||
}
|
||||
plugin, getErr := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if getErr != nil {
|
||||
return getErr
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.PollIntervalSeconds <= 0 || template.RowTarget == nil || strings.TrimSpace(template.SQLRef) == "" {
|
||||
continue
|
||||
}
|
||||
if !pluginQueryTemplateDue(jobs, instance.ID, template.Key, time.Duration(template.PollIntervalSeconds)*time.Second, stamp) {
|
||||
continue
|
||||
}
|
||||
bucket := stamp.Unix() / int64(template.PollIntervalSeconds)
|
||||
idempotencyKey := fmt.Sprintf("plugin-query:%s:%s:%d", instance.ID, template.Key, bucket)
|
||||
job := domain.Job{
|
||||
ID: jobIDFromParts("job-plugin-query", instance.ID, idempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: runEndpointID,
|
||||
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
TargetKey: template.TargetKey,
|
||||
InputRef: "input://plugin-query/" + template.Key,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "declared automatic plugin query queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
|
||||
ExecutionInput: domain.JobExecutionInput{
|
||||
WorkspaceScope: svc.runtimeProfileScope(instance.ID),
|
||||
RemoteAdapterKey: template.TransportKey,
|
||||
RemoteAdapterKind: string(domain.RemoteAdapterDatabase),
|
||||
TimeoutSeconds: template.TimeoutSeconds,
|
||||
Inputs: map[string]string{
|
||||
"templateKey": template.Key,
|
||||
"sqlRef": template.SQLRef,
|
||||
"maxRows": strconv.Itoa(template.MaxRows),
|
||||
"limit": strconv.Itoa(template.MaxRows),
|
||||
},
|
||||
},
|
||||
}
|
||||
created, createErr := svc.CreateJob(job)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
jobs = append(jobs, created)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateDue(jobs []domain.Job, serverInstanceID, templateKey string, interval time.Duration, stamp time.Time) bool {
|
||||
var latest time.Time
|
||||
for _, job := range jobs {
|
||||
if job.ServerInstanceID != serverInstanceID || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionInput.Inputs["templateKey"] != templateKey {
|
||||
continue
|
||||
}
|
||||
if !isTerminalJobState(job.State) {
|
||||
return false
|
||||
}
|
||||
attemptedAt := job.TerminalAt
|
||||
if attemptedAt.IsZero() {
|
||||
attemptedAt = job.UpdatedAt
|
||||
}
|
||||
if attemptedAt.After(latest) {
|
||||
latest = attemptedAt
|
||||
}
|
||||
}
|
||||
return latest.IsZero() || !stamp.Before(latest.Add(interval))
|
||||
}
|
||||
@@ -167,6 +167,92 @@ func TestDeclaredSQLiteQueryProjectionRejectsInvalidBatchAtomically(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionMergesPresenceAndReplacesCompleteSnapshots(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
template := &plugin.GameClientBridge.QueryTemplates[0]
|
||||
template.RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
Collection: "users", UpsertKeys: []string{"steamId"}, WriteMode: domain.PluginDataRowWriteModeMerge,
|
||||
ColumnMappings: map[string]string{"steamId": "steam_id", "displayName": "display_name", "x": "x"},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update merge target: %v", err)
|
||||
}
|
||||
if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "online": true, "lastLoginAt": "2026-07-03T12:00:00Z"}}); err != nil {
|
||||
t.Fatalf("seed stdout user: %v", err)
|
||||
}
|
||||
job := domain.Job{ServerInstanceID: instance.ID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, State: domain.JobStateSucceeded, ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": template.Key}}, ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"steam_id":"steam-1","display_name":"Ada","x":12.5}]}`}}
|
||||
if err := svc.projectPluginDataJobResult(job); err != nil {
|
||||
t.Fatalf("merge query projection: %v", err)
|
||||
}
|
||||
users, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"})
|
||||
if err != nil || len(users) != 1 || users[0].Value["online"] != true || users[0].Value["displayName"] != "Ada" {
|
||||
t.Fatalf("merged users=%+v err=%v", users, err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
Collection: "vehicles", UpsertKeys: []string{"vehicleId"}, WriteMode: domain.PluginDataRowWriteModeReplace,
|
||||
ColumnMappings: map[string]string{"vehicleId": "vehicle_id", "x": "x"},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update replace target: %v", err)
|
||||
}
|
||||
for _, id := range []string{"keep", "gone"} {
|
||||
if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles", Key: id, Value: map[string]any{"vehicleId": id}}); err != nil {
|
||||
t.Fatalf("seed vehicle %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
job.ExecutionResult.Content = `{"rows":[{"vehicle_id":"keep","x":7}]}`
|
||||
if err := svc.projectPluginDataJobResult(job); err != nil {
|
||||
t.Fatalf("replace query projection: %v", err)
|
||||
}
|
||||
vehicles, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"})
|
||||
if err != nil || len(vehicles) != 1 || vehicles[0].Key != "keep" {
|
||||
t.Fatalf("replaced vehicles=%+v err=%v", vehicles, err)
|
||||
}
|
||||
job.ExecutionResult.Content = `{"rows":[]}`
|
||||
if err := svc.projectPluginDataJobResult(job); err != nil {
|
||||
t.Fatalf("empty replace query projection: %v", err)
|
||||
}
|
||||
vehicles, err = svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"})
|
||||
if err != nil || len(vehicles) != 0 {
|
||||
t.Fatalf("empty replace did not clear vehicles=%+v err=%v", vehicles, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPollSchedulesDueDeclaredPluginQueryWithoutBrowserSession(t *testing.T) {
|
||||
svc, plugin, endpoint, _, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("enable automatic query: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil {
|
||||
t.Fatalf("automatic query claim=%+v err=%v", claim, err)
|
||||
}
|
||||
if claim.Job.ServerInstanceID != instance.ID || claim.Job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("unexpected automatic query assignment: %+v", claim.Job)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("automatic query jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1, RunningJobs: 1}})
|
||||
if err != nil || second.HasJob {
|
||||
t.Fatalf("overlapping automatic query was not suppressed: %+v err=%v", second, err)
|
||||
}
|
||||
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("overlap created duplicate jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
type pluginLogSequenceState struct {
|
||||
StepIndex int
|
||||
Captures map[string]string
|
||||
LastSeq uint64
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error {
|
||||
if stream.Source != domain.LogStreamSourceProcess || len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, projection := range plugin.GameClientBridge.LogProjections {
|
||||
if !containsString(projection.StreamKeys, stream.StreamKey) {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
captures, complete, matchErr := svc.advancePluginLogProjection(stream, projection, entry)
|
||||
if matchErr != nil {
|
||||
return matchErr
|
||||
}
|
||||
if complete {
|
||||
observedAt := entry.Timestamp
|
||||
if observedAt.IsZero() {
|
||||
observedAt = svc.now()
|
||||
}
|
||||
if err := svc.applyPluginLogProjection(instance, plugin, projection, captures, observedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) advancePluginLogProjection(stream domain.LogStream, projection domain.GameClientBridgeLogProjectionDeclaration, entry domain.LogEntry) (map[string]string, bool, error) {
|
||||
if len(projection.Steps) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
stateKey := strings.Join([]string{stream.ServerInstanceID, stream.ID, stream.LogSessionID, projection.Key}, "\x00")
|
||||
svc.logProjectionMu.Lock()
|
||||
defer svc.logProjectionMu.Unlock()
|
||||
states := svc.logProjectionStates[stateKey]
|
||||
if states == nil {
|
||||
states = map[string]pluginLogSequenceState{}
|
||||
svc.logProjectionStates[stateKey] = states
|
||||
}
|
||||
nextStates := make(map[string]pluginLogSequenceState, len(states)+1)
|
||||
var completed map[string]string
|
||||
for correlationKey, state := range states {
|
||||
if state.StepIndex < 1 || state.StepIndex >= len(projection.Steps) {
|
||||
continue
|
||||
}
|
||||
if projection.MaxInterveningLines >= 0 && state.LastSeq > 0 && entry.Seq > state.LastSeq+uint64(projection.MaxInterveningLines)+1 {
|
||||
continue
|
||||
}
|
||||
match, err := matchLogProjectionStep(projection.Steps[state.StepIndex].Pattern, entry.Line)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if match == nil {
|
||||
nextStates[correlationKey] = state
|
||||
continue
|
||||
}
|
||||
merged, ok := mergeLogCaptures(state.Captures, match)
|
||||
if !ok || !correlationCapturesAgree(state.Captures, match, projection.CorrelationFields) {
|
||||
continue
|
||||
}
|
||||
if state.StepIndex+1 == len(projection.Steps) {
|
||||
completed = merged
|
||||
continue
|
||||
}
|
||||
nextKey := logCorrelationKey(merged, projection.CorrelationFields)
|
||||
nextStates[nextKey] = pluginLogSequenceState{StepIndex: state.StepIndex + 1, Captures: merged, LastSeq: entry.Seq}
|
||||
}
|
||||
first, err := matchLogProjectionStep(projection.Steps[0].Pattern, entry.Line)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if first != nil {
|
||||
if len(projection.Steps) == 1 {
|
||||
completed = first
|
||||
} else {
|
||||
key := logCorrelationKey(first, projection.CorrelationFields)
|
||||
nextStates[key] = pluginLogSequenceState{StepIndex: 1, Captures: first, LastSeq: entry.Seq}
|
||||
}
|
||||
}
|
||||
svc.logProjectionStates[stateKey] = nextStates
|
||||
return completed, completed != nil, nil
|
||||
}
|
||||
|
||||
func matchLogProjectionStep(pattern, line string) (map[string]string, error) {
|
||||
expression, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, validationError("declared log projection pattern is invalid")
|
||||
}
|
||||
values := expression.FindStringSubmatch(line)
|
||||
if values == nil {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for index, name := range expression.SubexpNames() {
|
||||
if index > 0 && name != "" && index < len(values) {
|
||||
result[name] = values[index]
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mergeLogCaptures(existing, incoming map[string]string) (map[string]string, bool) {
|
||||
merged := make(map[string]string, len(existing)+len(incoming))
|
||||
for key, value := range existing {
|
||||
merged[key] = value
|
||||
}
|
||||
for key, value := range incoming {
|
||||
if previous, exists := merged[key]; exists && previous != value {
|
||||
return nil, false
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
return merged, true
|
||||
}
|
||||
|
||||
func correlationCapturesAgree(existing, incoming map[string]string, fields []string) bool {
|
||||
for _, field := range fields {
|
||||
left, leftExists := existing[field]
|
||||
right, rightExists := incoming[field]
|
||||
if leftExists && rightExists && left != right {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func logCorrelationKey(captures map[string]string, fields []string) string {
|
||||
parts := make([]string, len(fields))
|
||||
for index, field := range fields {
|
||||
parts[index] = captures[field]
|
||||
}
|
||||
return strings.Join(parts, "\x1f")
|
||||
}
|
||||
|
||||
func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, plugin domain.GamePlugin, projection domain.GameClientBridgeLogProjectionDeclaration, captures map[string]string, observedAt time.Time) error {
|
||||
value := pluginLogProjectionValue(projection.Target, captures, observedAt)
|
||||
key, err := pluginDataRowKey(value, projection.Target.UpsertKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Target.Collection, key))
|
||||
isNew := errors.Is(getErr, repo.ErrNotFound)
|
||||
if getErr != nil && !isNew {
|
||||
return getErr
|
||||
}
|
||||
insideWindow := false
|
||||
sameObservation := false
|
||||
if projection.Presence != nil && !isNew {
|
||||
if previous, ok := pluginDataTimestamp(existing.Value[projection.Presence.TimestampField]); ok {
|
||||
if observedAt.Before(previous) {
|
||||
return nil
|
||||
}
|
||||
sameObservation = observedAt.Equal(previous)
|
||||
insideWindow = observedAt.Sub(previous) < time.Duration(projection.Presence.ActiveWindowSeconds)*time.Second
|
||||
}
|
||||
}
|
||||
if insideWindow && !sameObservation {
|
||||
return nil
|
||||
}
|
||||
announcementAlreadyQueued := false
|
||||
announcementIdempotencyKey := ""
|
||||
if projection.Presence != nil {
|
||||
announcementIdempotencyKey = fmt.Sprintf("log-projection:%s:%s:%d", projection.Key, key, observedAt.Unix()/int64(projection.Presence.ActiveWindowSeconds))
|
||||
_, commandErr := svc.store.GameClientBridgeCommands().GetByIdempotency(instance.ID, "system:log-projection", projection.Presence.Announcement.CommandType, announcementIdempotencyKey)
|
||||
if commandErr == nil {
|
||||
announcementAlreadyQueued = true
|
||||
} else if !errors.Is(commandErr, repo.ErrNotFound) {
|
||||
return commandErr
|
||||
}
|
||||
}
|
||||
if !isNew {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
}
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Target.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: key, Value: value}}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if projection.Presence != nil && projection.Presence.ActivityTarget != nil {
|
||||
activity := pluginLogProjectionValue(*projection.Presence.ActivityTarget, captures, observedAt)
|
||||
activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys)
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
if _, applyErr := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Presence.ActivityTarget.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: activityKey, Value: activity}}}); applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
}
|
||||
if projection.Presence != nil && !announcementAlreadyQueued {
|
||||
announcement := projection.Presence.Announcement
|
||||
template := announcement.ReturningTextTemplate
|
||||
if isNew || sameObservation {
|
||||
template = announcement.NewTextTemplate
|
||||
}
|
||||
requestText := renderLogProjectionTemplate(template, captures)
|
||||
expiresAt := svc.now().Add(gameClientBridgeCommandTimeout(plugin, announcement.CommandType))
|
||||
if _, err := svc.queueGameClientBridgeCommand("system:log-projection", domain.GameClientBridgeQueueRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: announcement.ProfileKey,
|
||||
CommandType: announcement.CommandType,
|
||||
Payload: map[string]any{announcement.TextField: requestText},
|
||||
IdempotencyKey: announcementIdempotencyKey,
|
||||
Priority: 100,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandTimeout(plugin domain.GamePlugin, commandType string) time.Duration {
|
||||
for _, declaration := range plugin.GameClientBridge.Commands {
|
||||
if declaration.Type == commandType && declaration.TimeoutSeconds > 0 {
|
||||
return time.Duration(declaration.TimeoutSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
return time.Minute
|
||||
}
|
||||
|
||||
func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
|
||||
value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1)
|
||||
for destination, capture := range target.CaptureMappings {
|
||||
value[destination] = captures[capture]
|
||||
}
|
||||
for key, fixed := range target.FixedValues {
|
||||
value[key] = renderLogProjectionTemplate(fixed, captures)
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
value[target.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderLogProjectionTemplate(template string, captures map[string]string) string {
|
||||
result := template
|
||||
for key, value := range captures {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pluginDataTimestamp(value any) (time.Time, bool) {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, text)
|
||||
return parsed, err == nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnouncesReturns(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
capability := domain.JobCapabilityRemoteRunProtectedRCON
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{capability}})
|
||||
plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}})
|
||||
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{
|
||||
Type: "presence.announce", Title: "Presence announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator,
|
||||
PayloadSchemaRef: "schemas/presence-announcement.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096,
|
||||
ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 1024},
|
||||
}}
|
||||
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4,
|
||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||
{Pattern: `Player "(?P<displayName>[^"]+)" reported as player (?P<playerSlot>[0-9]+)`},
|
||||
{Pattern: `Player (?P<playerSlot>[0-9]+) SteamID \(assumed\): (?P<steamId>[0-9]+)`},
|
||||
},
|
||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{
|
||||
Collection: "scum_users", UpsertKeys: []string{"steamId"},
|
||||
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName", "playerSlot": "playerSlot"},
|
||||
FixedValues: map[string]string{"online": "true", "source": "supervised-stdout"}, ObservedAtField: "lastLoginAt",
|
||||
},
|
||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{
|
||||
Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt"},
|
||||
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "login"}, ObservedAtField: "observedAt",
|
||||
},
|
||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{
|
||||
ProfileKey: "scum-client", CommandType: "presence.announce", TextField: "requestText",
|
||||
NewTextTemplate: "#announce Welcome {{displayName}}", ReturningTextTemplate: "#announce Welcome back {{displayName}}",
|
||||
},
|
||||
},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin projection: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update Run capability: %v", err)
|
||||
}
|
||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-log-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM projection", State: domain.ServerInstanceStateRunning})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-log-projection"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
stream, err := svc.CreateLogStream(domain.LogStream{ID: "log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceProcess, StreamKey: "stdout", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout stream: %v", err)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 8, 18, 23, 25, 12, 0, time.UTC)
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`LogBattlEye: Display: Player #0 love_fitting (redacted) connected`,
|
||||
})
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 3, base.Add(2*time.Second), []string{
|
||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||
})
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1)
|
||||
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 4, base.Add(5*time.Minute), []string{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||
})
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1)
|
||||
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 6, base.Add(11*time.Minute), []string{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||
})
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 2)
|
||||
|
||||
svc.protectedRequests.mu.Lock()
|
||||
texts := make([]string, 0, len(svc.protectedRequests.payloads))
|
||||
for _, payload := range svc.protectedRequests.payloads {
|
||||
texts = append(texts, payload.requestText)
|
||||
}
|
||||
svc.protectedRequests.mu.Unlock()
|
||||
if len(texts) != 2 || !containsText(texts, "#announce Welcome love_fitting") || !containsText(texts, "#announce Welcome back love_fitting") {
|
||||
t.Fatalf("unexpected plugin-declared announcement requests: %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
|
||||
t.Helper()
|
||||
entries := make([]domain.LogEntry, len(lines))
|
||||
for index, line := range lines {
|
||||
entries[index] = domain.LogEntry{Seq: firstSeq + uint64(index), Timestamp: observedAt.Add(time.Duration(index) * time.Second), Level: "display", Line: line}
|
||||
}
|
||||
lastSeq := firstSeq + uint64(len(entries)) - 1
|
||||
batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: firstSeq, LastSeq: lastSeq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries}
|
||||
if result, err := svc.IngestLogBatch(batch); err != nil || !result.Accepted {
|
||||
t.Fatalf("ingest projection lines result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPresenceProjectionCounts(t *testing.T, svc *CoreService, pluginID, serverID string, users, activities, commands int) {
|
||||
t.Helper()
|
||||
userRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_users"})
|
||||
if err != nil || len(userRows) != users {
|
||||
t.Fatalf("projected users=%+v err=%v", userRows, err)
|
||||
}
|
||||
activityRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_activity_events"})
|
||||
if err != nil || len(activityRows) != activities {
|
||||
t.Fatalf("projected activities=%+v err=%v", activityRows, err)
|
||||
}
|
||||
queued, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverID, PluginID: pluginID})
|
||||
if err != nil || len(queued) != commands {
|
||||
t.Fatalf("presence announcements=%+v err=%v", queued, err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(value, expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -234,6 +234,8 @@ type CoreService struct {
|
||||
bridgeSeq uint64
|
||||
logStore LogBodyStore
|
||||
logIngestMu [64]sync.Mutex
|
||||
logProjectionMu sync.Mutex
|
||||
logProjectionStates map[string]map[string]pluginLogSequenceState
|
||||
logEventMu sync.Mutex
|
||||
logEventSubscribers map[uint64]logEventSubscriber
|
||||
logEventSubscriberSeq uint64
|
||||
@@ -280,6 +282,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
||||
authSessions: map[string]string{},
|
||||
runSessions: map[string]domain.RunControlSession{},
|
||||
logStore: logStore,
|
||||
logProjectionStates: map[string]map[string]pluginLogSequenceState{},
|
||||
logEventSubscribers: map[uint64]logEventSubscriber{},
|
||||
artifactStore: artifactStore,
|
||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||
|
||||
Reference in New Issue
Block a user