From c455e0fe510e1ebf32a959d6dee87ab845e15c72 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sat, 22 Aug 2026 11:52:49 +0200 Subject: [PATCH] fix: initialize late config bindings --- .../config/internal/sync/SnapshotApplier.kt | 39 +++++++++--- .../sync/ConfigScopeSynchronizerTest.kt | 63 +++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/common/src/main/kotlin/gg/grounds/config/internal/sync/SnapshotApplier.kt b/common/src/main/kotlin/gg/grounds/config/internal/sync/SnapshotApplier.kt index 3a8fffd..8e598e1 100644 --- a/common/src/main/kotlin/gg/grounds/config/internal/sync/SnapshotApplier.kt +++ b/common/src/main/kotlin/gg/grounds/config/internal/sync/SnapshotApplier.kt @@ -28,14 +28,7 @@ internal class SnapshotApplier( return } if (version == currentVersion) { - logger.debug( - "Config snapshot ignored (app={}, env={}, version={}, currentVersion={}, documents={}, reason=version_not_newer)", - scope.app, - scope.env, - version, - currentVersion, - documents.size, - ) + applyUninitializedBindings(scope, version, currentVersion, documents) return } val documentsByKey = @@ -65,6 +58,36 @@ internal class SnapshotApplier( } } + private fun applyUninitializedBindings( + scope: AppEnvScope, + version: Long, + currentVersion: Long, + documents: List, + ) { + val bindings = scope.bindingsSnapshot().filterValues { !it.initialized() } + if (bindings.isEmpty()) { + logger.debug( + "Config snapshot ignored (app={}, env={}, version={}, currentVersion={}, documents={}, reason=version_not_newer)", + scope.app, + scope.env, + version, + currentVersion, + documents.size, + ) + return + } + val documentsByKey = + documents.associateBy { document -> ConfigKey(document.namespace, document.configKey) } + for ((configKey, binding) in bindings) { + val document = documentsByKey[configKey] + if (document != null) { + applyDocument(binding, document.contentJson) + } else { + handleMissingDocument(scope, binding) + } + } + } + fun applyCachedSnapshot(scope: AppEnvScope, snapshot: ConfigSnapshot) { val documents = snapshot.documents.map { (configKey, contentJson) -> diff --git a/common/src/test/kotlin/gg/grounds/config/internal/sync/ConfigScopeSynchronizerTest.kt b/common/src/test/kotlin/gg/grounds/config/internal/sync/ConfigScopeSynchronizerTest.kt index 3cae16a..ade1ac7 100644 --- a/common/src/test/kotlin/gg/grounds/config/internal/sync/ConfigScopeSynchronizerTest.kt +++ b/common/src/test/kotlin/gg/grounds/config/internal/sync/ConfigScopeSynchronizerTest.kt @@ -226,6 +226,61 @@ class ConfigScopeSynchronizerTest { } } + @Test + fun `late registration applies an unchanged full snapshot to its new binding`() { + val client = + RecordingConfigSyncClient( + getSnapshotHandler = { + SnapshotResult( + changed = true, + version = 1, + documents = + listOf( + ConfigDocumentData( + namespace = TestStringConfig.namespace, + configKey = TestStringConfig.key, + contentJson = "\"first\"", + ), + ConfigDocumentData( + namespace = TestSecondConfig.namespace, + configKey = TestSecondConfig.key, + contentJson = "\"edge\"", + ), + ), + ) + } + ) + val executor = Executors.newSingleThreadScheduledExecutor() + val synchronizer = + ConfigScopeSynchronizer( + logger = LoggerFactory.getLogger("ConfigScopeSynchronizerLateRegistrationTest"), + configClientFactory = { client }, + natsListenerFactory = { RecordingConfigChangeListener() }, + refreshExecutorFactory = { executor }, + ) + val scope = AppEnvScope(app = "network", env = "stage") + val first = ConfigBinding(TestStringConfig) + scope.putBindingIfAbsent(ConfigKey(TestStringConfig.namespace, TestStringConfig.key), first) + + try { + synchronizer.start("http://config", "nats://localhost:4222") + synchronizer.bootstrap(scope, first, ConfigStartupMode.FAIL_CLOSED) + val late = ConfigBinding(TestSecondConfig) + scope.putBindingIfAbsent( + ConfigKey(TestSecondConfig.namespace, TestSecondConfig.key), + late, + ) + + synchronizer.bootstrap(scope, late, ConfigStartupMode.FAIL_CLOSED) + + assertTrue(late.initialized()) + assertEquals("edge", late.get()) + } finally { + synchronizer.close() + executor.shutdownNow() + } + } + @Test fun `bootstrap retries transient grpc failures`() { val retryDelays = CopyOnWriteArrayList() @@ -834,6 +889,14 @@ class ConfigScopeSynchronizerTest { defaultValue = "default", ) + private object TestSecondConfig : + ConfigDefinition( + namespace = "resourcepacks", + key = "global", + type = String::class.java, + defaultValue = "stable", + ) + private class RecordingConfigSyncClient( private val operations: MutableList = mutableListOf(), private val syncDefaultsHandler: RecordingConfigSyncClient.() -> SyncDefaultsResult = {