Prune stale plugin metadata

This commit is contained in:
npc0-hue
2026-09-14 17:32:51 +08:00
parent ecdca8e28b
commit 63e2340db7
10 changed files with 659 additions and 13 deletions
+3 -2
View File
@@ -308,7 +308,7 @@ func runtimeMetadataPath(path string) string {
}
func (store *FileStore) snapshot() StoreSnapshot {
return StoreSnapshot{
return normalizeStoreSnapshot(StoreSnapshot{
Users: snapshotRepository(store.MemoryStore.users),
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
@@ -337,7 +337,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles),
SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks),
SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks),
}
})
}
func (store *FileStore) runtimeSnapshot() runtimeSnapshot {
@@ -349,6 +349,7 @@ func (store *FileStore) runtimeSnapshot() runtimeSnapshot {
}
func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
snapshot = normalizeStoreSnapshot(snapshot)
loadRepository(store.MemoryStore.users, snapshot.Users)
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
+178
View File
@@ -0,0 +1,178 @@
package repo
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strings"
"browser.local/platform/domain"
)
func normalizeStoreSnapshot(snapshot StoreSnapshot) StoreSnapshot {
plugins, replacements := latestGamePlugins(snapshot.GamePlugins)
snapshot.GamePlugins = plugins
snapshot.ServerInstances = rewriteSnapshotServerInstances(snapshot.ServerInstances, replacements)
snapshot.RuntimeBindings = rewriteSnapshotRuntimeBindings(snapshot.RuntimeBindings, replacements)
snapshot.PluginLifecycles = rewriteSnapshotPluginLifecycles(snapshot.PluginLifecycles, replacements)
snapshot.AIConfigDiffs = rewriteSnapshotAIConfigDiffs(snapshot.AIConfigDiffs, replacements)
snapshot.PluginDataRecords = retainedSnapshotPluginData(snapshot.PluginDataRecords, replacements)
snapshot.Jobs = retainedSnapshotJobs(snapshot.Jobs, snapshot.ServerInstances, plugins)
return snapshot
}
func latestGamePlugins(values []domain.GamePlugin) ([]domain.GamePlugin, map[string]domain.GamePlugin) {
byIdentity := map[string]domain.GamePlugin{}
for _, plugin := range values {
key := domain.GamePluginIdentityKey(plugin)
if current, exists := byIdentity[key]; !exists || domain.GamePluginIsNewer(plugin, current) {
byIdentity[key] = plugin
}
}
replacements := map[string]domain.GamePlugin{}
out := make([]domain.GamePlugin, 0, len(byIdentity))
for _, plugin := range byIdentity {
out = append(out, domain.CopyGamePlugin(plugin))
}
sort.SliceStable(out, func(left, right int) bool { return out[left].ID < out[right].ID })
for _, plugin := range values {
kept, ok := byIdentity[domain.GamePluginIdentityKey(plugin)]
if ok && plugin.ID != kept.ID {
replacements[plugin.ID] = kept
}
}
return out, replacements
}
func rewriteSnapshotServerInstances(values []domain.ServerInstance, replacements map[string]domain.GamePlugin) []domain.ServerInstance {
out := make([]domain.ServerInstance, len(values))
for index, value := range values {
if kept, ok := replacements[value.PluginID]; ok {
value.PluginID = kept.ID
value.PluginVersion = kept.Version
}
out[index] = domain.CopyServerInstance(value)
}
return out
}
func rewriteSnapshotRuntimeBindings(values []domain.RuntimeBinding, replacements map[string]domain.GamePlugin) []domain.RuntimeBinding {
out := make([]domain.RuntimeBinding, len(values))
for index, value := range values {
if kept, ok := replacements[value.PluginID]; ok {
value.PluginID = kept.ID
value.PluginVersion = kept.Version
}
out[index] = domain.CopyRuntimeBinding(value)
}
return out
}
func rewriteSnapshotPluginLifecycles(values []domain.PluginLifecycleInstallation, replacements map[string]domain.GamePlugin) []domain.PluginLifecycleInstallation {
out := make([]domain.PluginLifecycleInstallation, len(values))
for index, value := range values {
if kept, ok := replacements[value.PluginID]; ok {
value.PluginID = kept.ID
if value.TargetVersion != "" {
value.TargetVersion = kept.Version
}
}
out[index] = domain.CopyPluginLifecycleInstallation(value)
}
return out
}
func rewriteSnapshotAIConfigDiffs(values []domain.AIConfigDiffPreview, replacements map[string]domain.GamePlugin) []domain.AIConfigDiffPreview {
out := make([]domain.AIConfigDiffPreview, len(values))
for index, value := range values {
if kept, ok := replacements[value.PluginID]; ok {
value.PluginID = kept.ID
}
out[index] = domain.CopyAIConfigDiffPreview(value)
}
return out
}
func retainedSnapshotPluginData(values []domain.PluginDataRecord, replacements map[string]domain.GamePlugin) []domain.PluginDataRecord {
byID := map[string]domain.PluginDataRecord{}
for _, value := range values {
if isLegacySCUMPluginData(value.PluginID, value.Collection) {
continue
}
if kept, ok := replacements[value.PluginID]; ok {
value.PluginID = kept.ID
value.ID = snapshotPluginDataID(value.ServerInstanceID, kept.ID, value.Collection, value.Key)
}
if current, exists := byID[value.ID]; !exists || value.UpdatedAt.After(current.UpdatedAt) || (value.UpdatedAt.Equal(current.UpdatedAt) && value.CreatedAt.After(current.CreatedAt)) {
byID[value.ID] = domain.CopyPluginDataRecord(value)
}
}
ids := make([]string, 0, len(byID))
for id := range byID {
ids = append(ids, id)
}
sort.Strings(ids)
out := make([]domain.PluginDataRecord, 0, len(ids))
for _, id := range ids {
out = append(out, domain.CopyPluginDataRecord(byID[id]))
}
return out
}
func retainedSnapshotJobs(values []domain.Job, instances []domain.ServerInstance, plugins []domain.GamePlugin) []domain.Job {
pluginByID := map[string]domain.GamePlugin{}
for _, plugin := range plugins {
pluginByID[plugin.ID] = plugin
}
serverPlugin := map[string]domain.GamePlugin{}
for _, instance := range instances {
if plugin, ok := pluginByID[instance.PluginID]; ok {
serverPlugin[instance.ID] = plugin
}
}
out := make([]domain.Job, 0, len(values))
for _, job := range values {
if isLegacySCUMSQLiteQueryJob(job, serverPlugin) {
continue
}
out = append(out, domain.CopyJob(job))
}
return out
}
func isLegacySCUMSQLiteQueryJob(job domain.Job, serverPlugin map[string]domain.GamePlugin) bool {
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery {
return false
}
plugin, ok := serverPlugin[job.ServerInstanceID]
if !ok {
return false
}
return isSCUMGamePlugin(plugin)
}
func isLegacySCUMPluginData(pluginID string, collection string) bool {
if !isSCUMPluginID(pluginID) {
return false
}
switch strings.ToLower(strings.TrimSpace(collection)) {
case "scum_users", "scum_players", "scum_trajectories", "scum_user_trajectories", "scum_vehicles", "scum_vehicle_trajectories", "scum_vehicle_locks":
return true
default:
return false
}
}
func isSCUMGamePlugin(plugin domain.GamePlugin) bool {
return strings.EqualFold(strings.TrimSpace(plugin.ServerType), "scum") || isSCUMPluginID(plugin.ID)
}
func isSCUMPluginID(pluginID string) bool {
canonical := domain.CanonicalGamePluginID(pluginID)
return canonical == "game.scum" || canonical == "server.scum"
}
func snapshotPluginDataID(serverID, pluginID, collection, key string) string {
sum := sha256.Sum256([]byte(serverID + "\x00" + pluginID + "\x00" + collection + "\x00" + key))
return "plugin-data-" + hex.EncodeToString(sum[:])[:24]
}
+3 -2
View File
@@ -264,7 +264,7 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq
}
func (store *MySQLStore) snapshot() StoreSnapshot {
return StoreSnapshot{
return normalizeStoreSnapshot(StoreSnapshot{
Users: snapshotRepository(store.MemoryStore.users),
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
@@ -293,7 +293,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles),
SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks),
SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks),
}
})
}
func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot {
@@ -305,6 +305,7 @@ func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot {
}
func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
snapshot = normalizeStoreSnapshot(snapshot)
loadRepository(store.MemoryStore.users, snapshot.Users)
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
+1
View File
@@ -46,6 +46,7 @@ type GamePluginRepository interface {
Get(id string) (domain.GamePlugin, error)
List(domain.GamePluginFilter) ([]domain.GamePlugin, error)
Update(domain.GamePlugin) error
Delete(id string) error
}
type ServerInstanceRepository interface {
+56
View File
@@ -255,6 +255,62 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
}
}
func TestFileStoreNormalizesPluginVersionsAndPrunesLegacySCUMSnapshotData(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
stalePlugin := domain.GamePlugin{ID: "game.scum.codex.20260804095301", Name: "SCUM Server", Version: "0.1.4", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json", Status: domain.GamePluginStatusInstalled}
latestPlugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM Server", Version: "0.1.15", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "artifact://manifests/game.scum/0.1.15", Status: domain.GamePluginStatusInstalled}
stamp := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC)
snapshot := StoreSnapshot{
GamePlugins: []domain.GamePlugin{stalePlugin, latestPlugin},
ServerInstances: []domain.ServerInstance{{ID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, Name: "SCUM", State: domain.ServerInstanceStateDraft, CreatedAt: stamp, UpdatedAt: stamp}},
RuntimeBindings: []domain.RuntimeBinding{{ID: "runtime-binding-server-scum", ServerInstanceID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: stamp, UpdatedAt: stamp}},
Jobs: []domain.Job{
{ID: "job-scum-sqlite-query", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, IdempotencyKey: "legacy-query", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp},
{ID: "job-scum-start", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunProgram, IdempotencyKey: "start", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp},
},
PluginDataRecords: []domain.PluginDataRecord{
{ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_gifts", "starter"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts", Key: "starter", Value: map[string]any{"name": "Starter"}, CreatedAt: stamp, UpdatedAt: stamp},
{ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_trajectories", "point-1"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_trajectories", Key: "point-1", Value: map[string]any{"source": "legacy"}, CreatedAt: stamp, UpdatedAt: stamp},
},
}
payload, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("marshal stale snapshot: %v", err)
}
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatalf("write stale snapshot: %v", err)
}
store, err := NewFileStore(path)
if err != nil {
t.Fatalf("reload stale snapshot: %v", err)
}
plugins, err := store.GamePlugins().List(domain.GamePluginFilter{ServerType: "scum"})
if err != nil || len(plugins) != 1 || plugins[0].ID != latestPlugin.ID || plugins[0].Version != latestPlugin.Version {
t.Fatalf("expected only latest plugin, plugins=%+v err=%v", plugins, err)
}
server, err := store.ServerInstances().Get("server-scum")
if err != nil || server.PluginID != latestPlugin.ID || server.PluginVersion != latestPlugin.Version {
t.Fatalf("expected server reference migration, server=%+v err=%v", server, err)
}
binding, err := store.RuntimeBindings().Get("runtime-binding-server-scum")
if err != nil || binding.PluginID != latestPlugin.ID || binding.PluginVersion != latestPlugin.Version {
t.Fatalf("expected runtime binding migration, binding=%+v err=%v", binding, err)
}
config, err := store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: latestPlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts"})
if err != nil || len(config) != 1 || config[0].Key != "starter" {
t.Fatalf("expected plugin-owned config data to migrate, data=%+v err=%v", config, err)
}
legacy, err := store.PluginDataRecords().List(domain.PluginDataFilter{ServerInstanceID: "server-scum", Collection: "scum_trajectories"})
if err != nil || len(legacy) != 0 {
t.Fatalf("expected legacy SCUM projection data to be pruned, data=%+v err=%v", legacy, err)
}
jobs, err := store.Jobs().List(domain.JobFilter{ServerInstanceID: "server-scum"})
if err != nil || len(jobs) != 1 || jobs[0].ID != "job-scum-start" {
t.Fatalf("expected legacy SCUM sqlite query job to be pruned, jobs=%+v err=%v", jobs, err)
}
}
func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
stamp := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
source := &MySQLStore{MemoryStore: NewMemoryStore()}