fix(core): clear changedKeys at start of config reload to prevent stale notifications#6756
fix(core): clear changedKeys at start of config reload to prevent stale notifications#6756bluestreak01 merged 1 commit intomasterfrom
Conversation
…le notifications When reload() was called and only a secret file had changed (properties file unchanged), updateSupportedProperties() was skipped—and with it the only call to changedKeys.clear(). The set therefore accumulated keys from prior reloads, causing watchers to be notified about properties that had not actually changed. Move the clear to the top of reload() so every cycle starts with a fresh set regardless of which code path is taken. Co-Authored-By: Claude Opus 4.6 <[email protected]>
WalkthroughThe PR modifies Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
[PR Coverage check]😍 pass : 1 / 1 (100.00%) file detail
|
|
@CodeRabbit review critically |
🧩 Analysis chainI'll review this PR critically. Let me gather the implementation details first. 🏁 Script executed: Length of output: 4246 🏁 Script executed: Length of output: 23198 🏁 Script executed: Length of output: 67875 🏁 Script executed: Length of output: 19621
Critical Issues1. Breaking Change to Public Static Method Contract (Line 229)The Recommendation: Either document this as a breaking change for enterprise code, or add a parameter to control clearing behavior. 2. Inconsistent State on Exception (Lines 451-454)If Question: Should we wrap the entire mutation block in try-finally and clear 3. Incomplete Test CoverageThe test a) Missing verification: The test never confirms the password actually changed to // Missing after line 1486:
Assert.assertEquals("rotated_password",
serverMain.getConfiguration().getPGWireConfiguration().getDefaultPassword());b) Scenario not tested: Secret-file-only reload as the first reload (no prior property changes). The bug could still exist in that case if c) Scenario not tested: Rotating a secret file for a property that IS being watched (e.g., watching 4. Lock Held During User Callbacks (Lines 460, 117-121)The Design question: Should notifications happen asynchronously or outside the synchronized block? 5. Edge Case: IOException Before Any Mutation (Lines 436-440)If loading
Is this acceptable behavior? It violates atomicity—either reload succeeds fully or leaves state unchanged. Minor Issues6. Memory AccumulationThe Proposed Test Enhancement`@Test`
public void testSecretFileOnlyReloadAsFirstReload() throws Exception {
// Test secret rotation when NO prior property changes occurred
Path secretFilePath = Paths.get(temp.getRoot().getAbsolutePath(), "secrets", "pg_password.txt");
Files.createDirectories(secretFilePath.getParent());
Files.writeString(secretFilePath, "initial_password");
try (FileWriter w = new FileWriter(serverConf)) {
w.write("pg.password.file=" + secretFilePath.toString().replace("\\", "\\\\") + "\n");
}
try (ServerMain serverMain = new ServerMain(getBootstrap())) {
serverMain.start();
final AtomicLong listenerCounter = new AtomicLong(0);
// Register listener for a property that's NOT changing
serverMain.getEngine().getConfigReloader().watch(new ConfigReloader.Listener() {
`@Override`
public void configChanged() { listenerCounter.incrementAndGet(); }
`@Override`
public `@NotNull` ConfigPropertyKey[] getWatchedConfigKeys() {
return new ConfigPropertyKey[]{ PropertyKey.CAIRO_SQL_ASOF_JOIN_EVACUATION_THRESHOLD };
}
});
// Rotate secret file - listener should NOT fire since evacuation.threshold unchanged
Files.writeString(secretFilePath, "rotated_password");
serverMain.getEngine().getConfigReloader().reload();
Assert.assertEquals(0, listenerCounter.get());
// Verify password actually changed!
Assert.assertEquals("rotated_password",
serverMain.getConfiguration().getPGWireConfiguration().getDefaultPassword());
}
}Bottom LineThe fix addresses the immediate bug but introduces a breaking change and has fragile exception handling. The test is insufficient—it doesn't verify the secret rotation actually worked and misses edge cases. Bump this back for:
🧠 Learnings used✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@core/src/test/java/io/questdb/test/DynamicPropServerConfigurationTest.java`:
- Around line 1443-1500: Move the test method
testSecretFileRotationDoesNotNotifyStaleWatchers so it is alphabetically ordered
with other test methods: place it between the existing
testSecretFileProcPathRejected and testSecretFileSysPathRejected methods in
DynamicPropServerConfigurationTest; ensure imports and surrounding
code/annotations remain unchanged and run tests to verify no ordering-related
issues.
🧹 Nitpick comments (1)
core/src/test/java/io/questdb/test/DynamicPropServerConfigurationTest.java (1)
1490-1497: Consider also asserting that the rotated secret was actually applied.The test only checks the listener wasn't re-notified, but doesn't verify the password was updated to
"rotated_password". Adding an assertion on the actual config value would strengthen confidence that the secret rotation itself succeeded while still not triggering the unrelated watcher.💡 Suggested addition after line 1491
Files.writeString(secretFilePath, "rotated_password"); serverMain.getEngine().getConfigReloader().reload(); + // Verify the rotated password was actually applied + Assert.assertEquals( + "rotated_password", + serverMain.getConfiguration().getPGWireConfiguration().getDefaultPassword() + ); + Assert.assertEquals( "listener for evacuation.threshold should not fire on secret file rotation", 1, listenerCounter.get() );
| @Test | ||
| public void testSecretFileRotationDoesNotNotifyStaleWatchers() throws Exception { | ||
| assertMemoryLeak(() -> { | ||
| // Create a secret file for pg.password | ||
| Path secretFilePath = Paths.get(temp.getRoot().getAbsolutePath(), "secrets", "pg_password.txt"); | ||
| Files.createDirectories(secretFilePath.getParent()); | ||
| Files.writeString(secretFilePath, "initial_password"); | ||
|
|
||
| // Initial config: a dynamic property + a secret file reference | ||
| try (FileWriter w = new FileWriter(serverConf)) { | ||
| w.write("cairo.sql.asof.join.evacuation.threshold=100\n"); | ||
| w.write("pg.password.file=" + secretFilePath.toString().replace("\\", "\\\\") + "\n"); | ||
| } | ||
|
|
||
| try (ServerMain serverMain = new ServerMain(getBootstrap())) { | ||
| serverMain.start(); | ||
|
|
||
| // Register a listener that watches ONLY the evacuation threshold | ||
| final AtomicLong listenerCounter = new AtomicLong(0); | ||
| final var listener = new ConfigReloader.Listener() { | ||
| private final ConfigPropertyKey[] WATCHED_PROPERTIES = new ConfigPropertyKey[]{ | ||
| PropertyKey.CAIRO_SQL_ASOF_JOIN_EVACUATION_THRESHOLD | ||
| }; | ||
|
|
||
| @Override | ||
| public void configChanged() { | ||
| listenerCounter.incrementAndGet(); | ||
| } | ||
|
|
||
| @Override | ||
| public @NotNull ConfigPropertyKey[] getWatchedConfigKeys() { | ||
| return WATCHED_PROPERTIES; | ||
| } | ||
| }; | ||
| serverMain.getEngine().getConfigReloader().watch(listener); | ||
|
|
||
| // [1] Change the evacuation threshold in server.conf → listener should fire | ||
| try (FileWriter w = new FileWriter(serverConf)) { | ||
| w.write("cairo.sql.asof.join.evacuation.threshold=200\n"); | ||
| w.write("pg.password.file=" + secretFilePath.toString().replace("\\", "\\\\") + "\n"); | ||
| } | ||
| serverMain.getEngine().getConfigReloader().reload(); | ||
| Assert.assertEquals(1, listenerCounter.get()); | ||
|
|
||
| // [2] Now rotate ONLY the secret file. Do NOT touch server.conf. | ||
| // The listener watches evacuation.threshold which hasn't changed, | ||
| // so it should NOT fire again. | ||
| Files.writeString(secretFilePath, "rotated_password"); | ||
| serverMain.getEngine().getConfigReloader().reload(); | ||
|
|
||
| Assert.assertEquals( | ||
| "listener for evacuation.threshold should not fire on secret file rotation", | ||
| 1, | ||
| listenerCounter.get() | ||
| ); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Place this test method in alphabetical order among its siblings.
Per coding guidelines, methods should be sorted alphabetically. testSecretFileRotationDoesNotNotifyStaleWatchers should sit between testSecretFileProcPathRejected (line 1263) and testSecretFileSysPathRejected (line 1281), not after testUnknownPropertyAdditionIsIgnored.
As per coding guidelines, "Group Java class members by kind and sort alphabetically. When adding new methods or fields, insert them in the correct alphabetical position among existing members of the same kind."
🤖 Prompt for AI Agents
In `@core/src/test/java/io/questdb/test/DynamicPropServerConfigurationTest.java`
around lines 1443 - 1500, Move the test method
testSecretFileRotationDoesNotNotifyStaleWatchers so it is alphabetically ordered
with other test methods: place it between the existing
testSecretFileProcPathRejected and testSecretFileSysPathRejected methods in
DynamicPropServerConfigurationTest; ensure imports and surrounding
code/annotations remain unchanged and run tests to verify no ordering-related
issues.
Summary
DynamicPropServerConfiguration.reload()accumulates stale entries inchangedKeysacross reload cycles when only a secret file changes (properties file unchanged), causing watchers to be spuriously notified about properties that did not change.changedKeys.clear()only lived insideupdateSupportedProperties(), which is skipped when the properties file hasn't changed. Secret-file-only reloads added keys on top of the stale set.changedKeys.clear()to the top ofreload(), inside thesynchronizedblock, so every reload cycle starts fresh.Test plan
testSecretFileRotationDoesNotNotifyStaleWatchers: registers a listener on a dynamic property, reloads after changing that property (listener fires), then rotates only a secret file and reloads again — asserts the listener does not fire a second time.DynamicPropServerConfigurationTestsuite passes (40 tests).🤖 Generated with Claude Code