feat(plugins): restrict non-hook plugin initialization to a single worker across instances - #5430
Conversation
bd8948d to
80d715d
Compare
47d26b6 to
6ca614f
Compare
34ef4e7 to
f84a108
Compare
d35b220 to
e7fa770
Compare
e548165 to
24c9011
Compare
msureshkumar88
left a comment
There was a problem hiding this comment.
Nice work here — the opt-in design (default stays filelock, redis backend fails closed, lifespan wiring is clean) and the test suite (fakeredis-based unit tests + backend-aware e2e + the docker-compose multi-instance script) are a strong foundation. Requesting changes for one correctness issue that goes to the core guarantee this PR is built around.
Blocking
filelock_fallback doesn't reset _is_primary on a mid-heartbeat Redis error — can produce two simultaneous primaries.
In leader_election.py, _maintain():
except Exception as exc:
if self._policy == "fail_closed":
self._is_primary = False
logger.warning("election maintenance error pid=%d: %s", os.getpid(), exc)_handle_redis_unavailable() (the code that actually does _acquire_filelock()) is only invoked from _start_redis() on the initial connect. If a node is already primary and Redis becomes unreachable mid-heartbeat under filelock_fallback, _is_primary stays True — the except block only demotes for fail_closed. The lease then expires unrenewed, another instance's follower loop wins it via SET NX EX, and now two processes both report primary. That's the exact split-brain this PR exists to prevent.
It also doesn't match the docs — .env.example/configuration.md describe filelock_fallback as "Redis down → per-host" without qualifying "startup only," so a reader would reasonably expect fallback to kick in here too.
Suggested fix: route the maintenance-loop except block through _handle_redis_unavailable(exc) for both policies (uniform handling), or explicitly scope filelock_fallback to startup-only in the docs/setting description and steer long-running deployments toward fail_closed. Given the stated intent ("fail_closed preserves the global guarantee"), the former seems like what was intended.
Would also be good to add a unit test for this case — test_redis_maintenance_error_fail_closed exists, but there's no equivalent for filelock_fallback + mid-heartbeat error, which is exactly the gap that let this through.
Suggestions (non-blocking)
- Duplication:
_default_lock_path()inleader_election.pyreimplements_lock_path()frommcpgateway/utils/primary_worker.py(same port-scoped temp-file logic, samePRIMARY_WORKER_LOCK_PATHoverride — the docstring even says "mirrors mcpgateway.utils.primary_worker"). Exporting and reusing the existing helper would avoid keeping two copies in sync by hand. - Falsy-default pattern:
PrimaryWorkerElector.__init__usesparam or settings.xxxforlease_ttl,heartbeat_interval,redis_key, etc. An explicit0or""would silently fall back to the settings default rather than being honored. Not exploitable today since nothing passes 0, butparam is Nonewould be more robust and matches the pattern already tested elsewhere (test_empty_string_override_falls_back_to_default). - Shutdown robustness:
stop()only swallowsasyncio.CancelledErrorwhen awaiting the cancelled maintenance task; any other exception there would propagate out ofstop_primary_worker_elector()and could interrupt the rest of themain.pyshutdown sequence. Low likelihood given the loop's own internalexcept Exception, but wrapping it the same best-effort way as the redis-release block right below would make shutdown unconditionally safe.
Minor / non-code
- No issue number referenced in the PR body or commits — worth linking a tracking issue if one exists, per the repo's commit/PR conventions.
Everything else looks solid: no breaking change (default backend unchanged), no dead code, docs are consistent across .env.example/configuration.md/lifecycle.md, and the implementation matches the PR description closely.
|
Fixed the |
|
Non-blocking suggestions also addressed in |
2a0885f to
5d691d1
Compare
msureshkumar88
left a comment
There was a problem hiding this comment.
Security hardening — possible credential leak in logs (CWE-532)
mcpgateway/services/leader_election.py, lines 194, 197, and 222 log the raw exception string when Redis is unreachable:
logger.warning("redis unavailable (%s); falling back to per-host filelock", exc) # line 194
logger.warning("redis unavailable (%s); failing closed (non-primary)", exc) # line 197
logger.warning("election maintenance error pid=%d: %s", os.getpid(), exc) # line 222If REDIS_URL (or PRIMARY_WORKER_REDIS_URL) is configured with embedded credentials (redis://user:pass@host:port), some redis.asyncio connection-error types stringify the connection target, so str(exc) could end up putting the password in the application log. Given fail_closed is the default policy, these lines are also the ones most likely to fire in a misconfigured or degraded environment — exactly when someone will be grepping logs.
Suggested fix: log type(exc).__name__ (and message only if you've confirmed it never embeds the DSN) instead of the raw exception, e.g.:
logger.warning("redis unavailable (%s); failing closed (non-primary)", type(exc).__name__)Everything else in this PR looks solid — fail-closed default, atomic Lua ops (parameterized via KEYS/ARGV, no injection risk), strong test coverage. Requesting changes only for this log-redaction item; happy to approve once addressed.
|
Fixed in |
msureshkumar88
left a comment
There was a problem hiding this comment.
Confirmed the log-redaction fix from the prior round: mcpgateway/services/leader_election.py now logs type(exc).__name__ instead of str(exc) at all three flagged sites (plus two more I hadn't caught, in the stop() cancel/cleanup paths), with an explicit # CWE-532 marker. Verified directly against the PR head commit (2b712a383). Resolving my prior request-changes.
Independent e2e verification
Ran a real (non-mocked) multi-container verification of the PR's core claim — built the gateway image from this branch and brought up the actual docker-compose stack (Postgres → PgBouncer → migration → Redis → 2 gateway replicas), not fakeredis/unit mocks.
Config:
- Image:
mcpgateway/mcpgateway:latestbuilt viamake dockerfrom this branch'sHEAD - Compose project
mcpgw-pr5430-verify,docker-compose.yml+ a host-port-only override (postgres/pgbouncer host ports remapped to avoid colliding with an already-running local stack — gateway reaches both via internal service DNS, not host ports, so this has no functional effect on the test) PLUGINS_CONFIG_FILE=plugins/primary_worker_multiinstance_config.yaml(the marker plugin shipped in this PR)GUNICORN_WORKERS=3,--scale gateway=2(6 workers total across 2 containers)
Commands:
make docker
docker compose -p mcpgw-pr5430-verify -f docker-compose.yml -f <port-override>.yml up -d redis
docker compose ... exec -T redis redis-cli DEL mcpgw:primary_worker:e2e:markers
GUNICORN_WORKERS=3 PRIMARY_WORKER_ELECTION_BACKEND=redis \
PLUGINS_CONFIG_FILE=plugins/primary_worker_multiinstance_config.yaml \
docker compose ... up -d --scale gateway=2 gateway
docker compose ... exec -T redis redis-cli SCARD mcpgw:primary_worker:e2e:markers
docker compose ... exec -T redis redis-cli SMEMBERS mcpgw:primary_worker:e2e:markers
# repeat with PRIMARY_WORKER_ELECTION_BACKEND=filelockOutput:
| Backend | Containers × workers | Distinct primaries (SCARD) |
Members |
|---|---|---|---|
redis |
2×3 = 6 | 1 | 6aa536f92127:31 |
filelock |
2×3 = 6 | 2 | 08f6adfa639b:32, 175d64465a29:33 |
RESULT: PASS (redis backend -> 1 primaries, expected 1)
RESULT: PASS (filelock backend -> 2 primaries, expected 2)
Confirms the PR's central claim on a real multi-container boot: the redis backend dedups election across replicas to exactly one primary, while filelock is confirmed limited to per-container election — the exact gap this PR closes.
One non-blocking note: the branch currently shows as having merge conflicts with main — worth a rebase before merge, but unrelated to the code review itself.
Approving — nice work carrying this through several rounds (split-brain fix, idempotent start/stop, heartbeat/TTL validation, and now the log redaction). Solid design and test coverage throughout.
🚨 Blocking IssuesTest Coverage Gaps (High Priority)
Medium Priority
Code Quality
|
2b712a3 to
d601882
Compare
|
Thanks for the thorough pass. Added three of these as targeted tests in
On the rest:
|
…ection Adds an opt-in Redis backend so a single primary can be elected across multiple gateway instances/replicas, not just per host. The default stays filelock (per-host), so existing single-host deployments are unchanged. - mcpgateway/services/leader_election.py: PrimaryWorkerElector with two backends. filelock is passive (OS releases on exit, no task); redis uses a lease (SET NX EX) renewed by an atomic compare-and-renew Lua heartbeat, released by an if-owner Lua, with a follower loop that re-acquires on expiry. - is_primary_worker() reads the elector's cached flag for the redis backend and fails closed if it isn't started (a redis deployment never silently degrades to per-host scope). - main.py lifespan starts the elector before plugin init (awaits the initial election) and stops it on shutdown; only the redis backend needs an elector. - Config (conservative defaults): PRIMARY_WORKER_ELECTION_BACKEND=filelock, PRIMARY_WORKER_REDIS_KEY, PRIMARY_WORKER_LEASE_TTL, PRIMARY_WORKER_HEARTBEAT_INTERVAL, PRIMARY_WORKER_REDIS_UNAVAILABLE_POLICY= fail_closed (filelock_fallback optional). - Tests with fakeredis cover single-primary, renew, release, follower takeover, fail-closed and filelock-fallback; e2e script is backend-aware. Election under the redis backend is best-effort under network partitions; keep side effects idempotent. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…the elector - e2e: add an ungated hook plugin and assert it loads on every worker (one marker line per worker), alongside the gated non-hook plugin asserting one. The e2e runner is backend-aware, so this holds under filelock and redis — election only gates the opt-in side effect, never the hook path. - unit: assert the default election backend is filelock and that the filelock path never consults the elector (locks the non-hook default behavior). Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…er election Scales the standard docker-compose gateway to 2 replicas with the redis election backend and asserts exactly one primary is elected across the containers — the cross-instance guarantee a per-host file lock cannot provide. - docker-compose.yml: expose PRIMARY_WORKER_ELECTION_BACKEND and PRIMARY_WORKER_REDIS_UNAVAILABLE_POLICY as env-configurable settings, defaulting to filelock / fail_closed (no behavior change by default). - plugins/primary_worker_multiinstance: a non-hook marker plugin that RPUSHes <host>:<pid> to a shared Redis list when primary, so the count is observable across containers (a per-container file marker cannot be). - run_primary_worker_multiinstance.sh + make test-primary-worker-multiinstance: redis up -> clear key -> up --scale gateway=2 -> wait healthy -> assert the list has exactly one entry. Not part of `make test` (needs Docker + a branch-built image). Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…nspection Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…-init) Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…n paths Raise diff coverage on the redis backend above the CI gate: exercise the maintenance loop (lost-lease demotion via compare-and-renew, mid-heartbeat error -> fail-closed), the own-client build/close path, the started/instance_id properties, and the module-level start/get/stop singleton helpers. Brings leader_election.py to 100% line coverage. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…back The redis elector's maintenance loop only demoted a stale primary for the fail_closed policy. Under filelock_fallback, a Redis error mid-heartbeat left _is_primary=True with no election performed: the lease then expired unrenewed, a follower acquired it via SET NX EX, and two processes reported primary -- the split-brain this backend prevents. Route the maintenance-loop error through _handle_redis_unavailable() for both policies, matching initial-connect behavior: fail_closed demotes; filelock_fallback re-elects per host (and demotes if the lock is held). Make _acquire_filelock() idempotent (reuse one FileLock, short-circuit when already held) so the loop can call it each heartbeat during an outage without churning the lock. Add regression tests for filelock_fallback + mid-heartbeat error: demotes when the file lock is held elsewhere, re-elects when it is free. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…ctor - Dedup the port-scoped lock-path helper: reuse _lock_path() from mcpgateway.utils.primary_worker instead of a hand-mirrored copy (aliased on import, so call sites are unchanged). No circular import — primary_worker only imports leader_election lazily. - Honor explicit falsy options: use 'x if x is not None else settings.y' rather than 'x or settings.y' for backend/redis_url/redis_key/lease_ttl/heartbeat/policy, so an explicit 0 or "" isn't silently replaced by the settings default. - Make stop() unconditionally best-effort: also swallow non-CancelledError errors from the cancelled maintenance task (matching the redis-release block) so a task error can't interrupt the rest of the shutdown sequence. Adds a shutdown-robustness test; leader_election.py stays at 100% line coverage. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…kend start() had no guard for already-started: a second call on the redis backend re-issued SET NX (which fails since we already hold the key) and demoted the active primary to False, and overwrote self._task with a new maintenance loop, orphaning the first (unreachable by stop()). This contradicts the documented "second start reuses the same singleton" contract; the existing singleton test only covered the idempotent filelock path, which masked it. Guard start() with an early return when already started. Also drop the owned Redis client in stop() (self._redis = None in a finally) so a repeat stop() doesn't aclose() an already-closed client; injected clients are left as-is. Add a redis-backend start()-idempotency regression test (is_primary stays True, maintenance task not replaced) and extend the own-client test to assert a second stop() is a no-op. leader_election.py stays at 100% coverage. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…dis election caveats Add a startup model-validator (redis backend only) that warns when PRIMARY_WORKER_HEARTBEAT_INTERVAL >= PRIMARY_WORKER_LEASE_TTL/2, since the lease would expire before renewal and cause continuous re-election. Warns rather than raises, matching the existing config-validator style (non-breaking). Document three redis-backend caveats in configuration.md: namespace the lease key when sharing one Redis across deployments, keep heartbeat < lease_ttl/2, and note that a boot-time Redis outage doesn't auto-recover (restart the worker). Related to #5139 Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…ings (CWE-532) A redis.asyncio connection error can stringify the connection target, so logging str(exc) could leak the password from REDIS_URL/PRIMARY_WORKER_REDIS_URL into the application log. Log type(exc).__name__ instead across all elector warnings (redis-unavailable, maintenance error, stop cleanup, task-cancel). Add a regression test that raises a ConnectionError with an embedded DSN mid- heartbeat and asserts the secret never reaches the log while the exception type still does. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
…t-boundary tests Harden coverage per review: - test_redis_concurrent_start_elects_one_primary: many electors start() via asyncio.gather, exactly one wins the lease (SET NX is atomic). - test_stop_swallows_redis_release_error: the lease-release eval raising during stop() is swallowed (best-effort shutdown), no propagation. - test_primary_worker_heartbeat_warns_at_exact_boundary: heartbeat == lease_ttl/2 warns (the requirement is strictly less than). leader_election.py stays at 100% coverage. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
baba551 to
bbb0cc7
Compare
📌 Summary
Adds an opt-in Redis backend for primary-worker election so a single primary can be elected across multiple gateway instances/replicas, not just per host. The default remains
filelock(per-host), so existing single-host deployments are unchanged.Related to #5139 (cross-instance follow-up to the per-host
is_primary_worker()work).💡 What it adds
mcpgateway/services/leader_election.py— aPrimaryWorkerElectorwith two backends behind the existingis_primary_worker()API:filelock(passive, default): one primary per host. The OS releases the lock on process exit; no background task.redis(active): one primary across all instances sharing a Redis. Lease viaSET NX EX, renewed by an atomic compare-and-renew Lua heartbeat, released by an if-owner Lua, with a follower loop that re-acquires on lease expiry.Wiring:
is_primary_worker()reads the elector's cached flag for the redis backend and fails closed if the elector isn't started — a redis deployment never silently degrades to per-host scope.⚙️ Config (conservative defaults)
PRIMARY_WORKER_ELECTION_BACKENDfilelockPRIMARY_WORKER_REDIS_KEYmcpgw:primary_workerPRIMARY_WORKER_LEASE_TTL15PRIMARY_WORKER_HEARTBEAT_INTERVAL5PRIMARY_WORKER_REDIS_UNAVAILABLE_POLICYfail_closed(orfilelock_fallback)No behavior change is triggered by
CACHE_TYPE; the backend is explicit.🧪 Verification
redisbackendfilelockbackend (regression)ruff/mypyon new files🐳 Multi-instance verification (manual)
Proves the cross-instance guarantee on a real multi-replica stack: two gateway containers sharing one Redis elect exactly one primary — something a per-host file lock cannot do.
make test-primary-worker-multiinstanceautomates this; the steps below let a reviewer drive it by hand and inspect the live election in Redis.Everything needed ships with the PR — the marker plugin (
plugins/primary_worker_multiinstance/marker.py, a minimal non-hook example gated onis_primary_worker()) and its config (plugins/primary_worker_multiinstance_config.yaml) are committed. The plugin is opt-in (loaded only via that config), so it never runs in a normal deployment. ItsSADD host:pidto a Redis set is e2e observability (so we can count primaries across containers), not a representative side effect — a real plugin puts its own work after the guard.Fast path:
Detailed step-by-step (click to expand)
Prerequisites
docker compose, run from the repo root on this branch.How the pieces reach the container (why step 1 is required)
Two layers arrive by different routes:
is_primary_worker(), the Redis elector, and the lifespan wiring — is compiled into the image. It must be rebuilt from this branch (make docker), or a stale image would load the plugin but run the old (filelock-only) election../plugins(docker-compose.yml:${PLUGINS_DIR:-./plugins}:/app/plugins:ro), so it comes from your working tree automatically — no rebuild needed for it.1. Build the image from this branch
2. Define a compose shortcut (zsh-safe: a function, since unquoted
$VARdoesn't word-split in zsh)3. Bring up Redis + 2 gateway replicas (redis backend)
REDIS_URLis already wired to theredisservice inside compose; you only set the backend, the plugin config, and a small worker count:This also starts the
postgres → pgbouncer → migrationdependencies automatically.4. Wait until both gateways are healthy
dc ps gateway # re-run until BOTH show "(healthy)" — ~30–60sGive it ~3s more after both are healthy so each replica finishes
initialize().5. Assert exactly one primary across the two containers
The marker plugin adds
<host>:<pid>to a shared Redis set only whenis_primary_worker()is true. A set (not a list) keeps the count correct even ifinitialize()runs more than once:Pass =
SCARDis1. Across all 6 workers (2 replicas × 3), exactly one is primary.6. Inspect the lease and heartbeat (optional)
7. Contrast — filelock backend gives 2 (the gap this PR closes)
A per-host file lock elects one primary per container, so the set has 2 members — exactly what the redis backend fixes.
8. Tear down
Expected results
SCARD mcpgw:primary_worker:e2e:markersredisfilelockTroubleshooting / gotchas
Bind for 0.0.0.0:6379 failed: port is already allocated— another Compose stack is publishing Redis on 6379. Find it withdocker ps --filter publish=6379, thendocker compose -p <that-project> -f docker-compose.yml down -v.GUNICORN_WORKERS=3— the compose default is 24 workers per container.down -v) reuses existing containers and the existing Redis set — no new election. Eitherdc down -vfirst, or clear the set (dc exec -T redis redis-cli DEL mcpgw:primary_worker:e2e:markers) and add--force-recreateto theupcommand.initialize()calls dedup;SCARDis the authoritative count.$VARdoesn't word-split; use thedc()function above (or${=VAR}), notC="docker compose …"; $C ….Requires Docker and brings up the standard compose stack (~1–2 min); not part of
make test.📓 Notes
is_primary_worker() -> bool. Leadership-change callbacks are intentionally deferred.GatewayServiceis not refactored here — this is additive; its leader election can migrate onto the shared elector later.