|
| 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