Skip to content

Commit cdf682c

Browse files
fylornclaude
andcommitted
test: T4-C — multi-instance config hot-reload via Redis Pub/Sub
In a multi-instance deployment, two backends sharing a Postgres + Redis must stay in lock-step on `system_settings` changes. The mechanism: writer persists, then publishes `reload` on the Redis `config:changed` channel; every subscriber re-reads the table. A silent regression that drops `notify_config_changed(...)` from an admin write path, or a subscriber whose error handler swallows the reload, would let one instance serve stale config until the next restart — the existing dynamic_config unit tests don't catch this because they only round-trip a single in-memory cache. The test stands up a second `DynamicConfig` + `SubscriberClient` inside one TestApp (instance B), drives a settings change through instance A's admin endpoint, and polls instance B's cache until it reflects the new value. Includes a 200ms grace period after SUBSCRIBE to avoid racing the publish, since Redis pub/sub is fire-and-forget. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c3528c0 commit cdf682c

1 file changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//! Multi-instance config hot-reload via Redis Pub/Sub.
2+
//!
3+
//! In production, two backend instances pointing at the same
4+
//! Postgres + Redis must stay in lock-step on `system_settings`
5+
//! changes — instance B's in-memory `DynamicConfig` cache should
6+
//! refresh within milliseconds of instance A persisting a write,
7+
//! without restart. The mechanism: writer publishes `reload` to
8+
//! `config:changed`; every subscriber re-reads the table.
9+
//!
10+
//! `dynamic_config.rs` has unit tests for `get`/`set` round-trips,
11+
//! but nothing exercises the cross-instance pub/sub edge: a silent
12+
//! regression that drops `notify_config_changed(...)` from the
13+
//! update path, or a subscriber that fails to reload, would let
14+
//! one instance serve stale config until the next restart.
15+
//!
16+
//! Test recipe: spawn TestApp (instance A's full stack), then
17+
//! manually build a *second* `DynamicConfig` plus a Redis
18+
//! `SubscriberClient` pointing at the same DB+Redis (instance B).
19+
//! Drive a setting change through instance A's admin endpoint and
20+
//! poll instance B's cache until it reflects the new value.
21+
22+
use std::sync::Arc;
23+
use think_watch_common::dynamic_config::{DynamicConfig, spawn_config_subscriber};
24+
use think_watch_test_support::prelude::*;
25+
26+
#[ignore = "integration test — run via `make test-it`"]
27+
#[tokio::test]
28+
async fn instance_b_picks_up_instance_a_setting_change_via_pubsub() {
29+
let app = TestApp::spawn().await;
30+
let admin = fixtures::create_admin_user(&app.db).await.unwrap();
31+
let con = app.console_client();
32+
con.post(
33+
"/api/auth/login",
34+
json!({"email": admin.user.email, "password": admin.plaintext_password}),
35+
)
36+
.await
37+
.unwrap()
38+
.assert_ok();
39+
40+
// -- Stand up instance B's DynamicConfig cache + subscriber.
41+
// Same DB pool (so it reads the same `system_settings` rows),
42+
// independent Redis subscriber on the shared `config:changed`
43+
// channel.
44+
let instance_b = Arc::new(
45+
DynamicConfig::load(app.db.clone())
46+
.await
47+
.expect("instance B DynamicConfig::load"),
48+
);
49+
let sub_cfg = fred::types::config::Config::from_url(&app.state.config.redis_url)
50+
.expect("parse redis_url");
51+
let subscriber: fred::clients::SubscriberClient = fred::types::Builder::from_config(sub_cfg)
52+
.build_subscriber_client()
53+
.expect("build_subscriber_client");
54+
fred::interfaces::ClientLike::init(&subscriber)
55+
.await
56+
.expect("subscriber init");
57+
spawn_config_subscriber(subscriber, instance_b.clone());
58+
59+
// Tiny grace period for the SUBSCRIBE command to round-trip
60+
// before instance A publishes — without it the publish can
61+
// race ahead of the subscription confirmation and the message
62+
// is silently dropped (Redis pub/sub is fire-and-forget).
63+
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
64+
65+
// -- Pick a setting key that already exists in `system_settings`
66+
// (the migrations seed retention defaults). We bump it to a
67+
// distinctive value so we can tell instance B saw THIS write
68+
// and not stale data from a parallel test.
69+
let key = "data.retention_days_audit";
70+
let want = 17_i64; // intentionally not the default of 90.
71+
72+
// -- Read instance B's `before` value so the assertion shows the
73+
// delta on failure.
74+
let before = instance_b.get(key).await;
75+
76+
// -- Drive the change through instance A's admin endpoint —
77+
// this is what hits `notify_config_changed` in production.
78+
con.patch(
79+
"/api/admin/settings",
80+
json!({
81+
"settings": {
82+
key: want
83+
}
84+
}),
85+
)
86+
.await
87+
.unwrap()
88+
.assert_ok();
89+
90+
// -- Poll instance B until it reloads. The pub/sub round-trip
91+
// is single-digit ms locally; allow up to 5s for slow CI.
92+
let mut got: Option<serde_json::Value> = None;
93+
for _ in 0..100 {
94+
let v = instance_b.get(key).await;
95+
if v.as_ref().and_then(|j| j.as_i64()) == Some(want) {
96+
got = v;
97+
break;
98+
}
99+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
100+
}
101+
assert_eq!(
102+
got.as_ref().and_then(|j| j.as_i64()),
103+
Some(want),
104+
"instance B never observed the new value: before={before:?} now={got:?}"
105+
);
106+
}

0 commit comments

Comments
 (0)