Skip to content

Commit a502f2e

Browse files
authored
Merge branch 'master' into david/per-15155-pr1-git-leakresilience-test-environment-one-big-pr
2 parents 15f3cfe + d30e462 commit a502f2e

15 files changed

Lines changed: 2669 additions & 24 deletions

.pre-commit-config.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# The pinned formatter versions below (black 23.1.0, isort 5.12.0, docformatter
2+
# 1.7.x) do not run on Python 3.13+, so pin the hook toolchain to 3.12 (the version
3+
# CI uses). Without this, hooks fail on machines whose default python is newer.
4+
default_language_version:
5+
python: python3.12
6+
17
repos:
28
- repo: https://github.com/pre-commit/pre-commit-hooks
39
rev: v5.0.0
@@ -18,8 +24,11 @@ repos:
1824
hooks:
1925
- id: codespell
2026
args: [--skip, "*.json,*.lock,*pnpm-lock.yaml"]
27+
# v1.7.6 dropped the `docformatter-venv` hook whose `language: python_venv` made
28+
# prek / pre-commit>=4 reject the whole manifest. Pinned to v1.7.6 (not v1.7.7,
29+
# whose docstring wrapping differs) so the bump introduces no reformatting.
2130
- repo: https://github.com/PyCQA/docformatter
22-
rev: v1.7.5
31+
rev: v1.7.6
2332
hooks:
2433
- id: docformatter
2534
args: [--in-place]

app-tests/docker-compose-app-tests.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ services:
4747
- POSTGRES_DB=postgres
4848
- POSTGRES_USER=postgres
4949
- POSTGRES_PASSWORD=postgres
50+
healthcheck:
51+
test: ["CMD-SHELL", "pg_isready -U postgres"]
52+
interval: 5s
53+
timeout: 3s
54+
retries: 10
5055

5156
opal_server:
5257
image: permitio/opal-server:${OPAL_IMAGE_TAG:-latest}
@@ -56,6 +61,15 @@ services:
5661
endpoint_mode: vip
5762
environment:
5863
- OPAL_BROADCAST_URI=postgres://postgres:postgres@broadcast_channel:5432/postgres
64+
# Make the broadcaster reconnect fast and replay after both replicas have
65+
# re-subscribed, so a cross-instance update published during a backbone
66+
# outage deterministically converges to both clients (see run.sh
67+
# "cross-instance consistency" test). With backoff_max=2 both servers
68+
# re-subscribe within ~2s of the DB returning, while settle=3 delays the
69+
# replay until after that, guaranteeing the replayed update is delivered.
70+
- OPAL_BROADCAST_RECONNECT_BACKOFF_MIN_SECONDS=0.5
71+
- OPAL_BROADCAST_RECONNECT_BACKOFF_MAX_SECONDS=2
72+
- OPAL_BROADCAST_RESYNC_SETTLE_SECONDS=3
5973
- UVICORN_NUM_WORKERS=4
6074
- OPAL_POLICY_REPO_URL=http://gitea:3000/gitea_admin/policy-repo.git
6175
- OPAL_POLICY_REPO_MAIN_BRANCH=${POLICY_REPO_BRANCH:-main}

app-tests/run.sh

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,33 @@ function check_no_error {
238238
fi
239239
}
240240

241+
function check_servers_logged {
242+
echo "- Looking for msg '$1' in server's logs"
243+
compose logs opal_server | grep -q "$1"
244+
}
245+
246+
function check_servers_not_logged {
247+
echo "- Ensuring msg '$1' is absent from server's logs"
248+
if compose logs opal_server | grep -q "$1"; then
249+
echo "- Unexpectedly found '$1' in server logs:"
250+
compose logs opal_server | grep "$1"
251+
exit 1
252+
fi
253+
}
254+
255+
function wait_for_broadcaster {
256+
echo "- Waiting for broadcast_channel to accept connections"
257+
for _ in $(seq 1 30); do
258+
if compose exec -T broadcast_channel pg_isready -U postgres -q; then
259+
echo " broadcast_channel is ready"
260+
return 0
261+
fi
262+
sleep 1
263+
done
264+
echo " broadcast_channel did not become ready in time"
265+
exit 1
266+
}
267+
241268
function clean_up {
242269
ARG=$?
243270
# Ensure we're in the script directory for cleanup
@@ -277,11 +304,9 @@ function test_push_policy {
277304
check_clients_logged "PUT /v1/policies/$regofile -> 200"
278305
}
279306

280-
function test_data_publish {
281-
echo "- Testing data publish for user $1"
307+
function publish_data {
308+
# POST a data update to a single OPAL server (no assertion).
282309
user=$1
283-
284-
# Use curl to publish data update via OPAL server API
285310
curl -s -X POST http://localhost:7002/data/config \
286311
-H "Authorization: Bearer $OPAL_DATA_SOURCE_TOKEN" \
287312
-H "Content-Type: application/json" \
@@ -294,9 +319,13 @@ function test_data_publish {
294319
"save_method": "PUT"
295320
}]
296321
}'
322+
}
297323

324+
function test_data_publish {
325+
echo "- Testing data publish for user $1"
326+
publish_data "$1"
298327
sleep 5
299-
check_clients_logged "PUT /v1/data/users/$user/location -> 204"
328+
check_clients_logged "PUT /v1/data/users/$1/location -> 204"
300329
}
301330

302331
function test_statistics {
@@ -344,15 +373,54 @@ function main {
344373
test_push_policy "something"
345374
test_statistics
346375

347-
echo "- Testing broadcast channel disconnection"
376+
echo "- Testing broadcast channel disconnection (graceful restart)"
348377
compose restart broadcast_channel
349-
sleep 10
378+
wait_for_broadcaster
379+
# Give the servers' reconnecting broadcaster a moment to re-establish the backbone
380+
sleep 5
350381

351382
test_data_publish "alice"
352383
test_push_policy "another"
384+
385+
echo "- Testing broadcast channel disconnection (ungraceful kill)"
386+
compose kill broadcast_channel
387+
sleep 3
388+
compose up -d broadcast_channel
389+
wait_for_broadcaster
390+
sleep 5
391+
353392
test_data_publish "sunil"
354393
test_data_publish "eve"
355394
test_push_policy "best_one_yet"
395+
396+
# Regression guards for the broadcaster-disconnect storm (see pubsub_resilience.py):
397+
# the servers must have reconnected to the backbone (this line is logged on every
398+
# (re)connect, so it fires on both the graceful-restart and ungraceful-kill paths),
399+
# and must NOT have spewed the non-idempotent-disconnect ValueError that drove the
400+
# fleet-wide drop storm.
401+
check_servers_logged "Broadcaster listener connected to channel"
402+
check_servers_not_logged "list.remove(x): x not in list"
403+
404+
# Cross-instance consistency: publish an update WHILE the backbone is down, then
405+
# recover. The two clients connect to different server replicas via the service VIP,
406+
# so for BOTH to end up with the value the missed cross-server update must converge
407+
# after recovery (via the replay buffer and/or the resync-on-reconnect path).
408+
echo "- Testing cross-instance consistency across a backbone outage"
409+
compose kill broadcast_channel
410+
sleep 3
411+
publish_data "consistency_user"
412+
sleep 2
413+
compose up -d broadcast_channel
414+
wait_for_broadcaster
415+
# allow buffered replay + (if needed) client resync + full refetch to settle
416+
sleep 15
417+
# The server that received the publish while the backbone was down must have
418+
# buffered it and replayed it on recovery (proves the replay path actually ran,
419+
# not just a client refetch).
420+
check_servers_logged "buffered for replay"
421+
check_servers_logged "Replaying"
422+
# BOTH clients (on different replicas via the VIP) must end up with the value.
423+
check_clients_logged "PUT /v1/data/users/consistency_user/location -> 204"
356424
# TODO: Test statistics feature again after broadcaster restart (should first fix statistics bug)
357425
}
358426

@@ -365,6 +433,14 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
365433
main && break
366434
RETRY_COUNT=$((RETRY_COUNT + 1))
367435
echo "Test failed, retrying..."
436+
# Tear the stack down before retrying so the next attempt starts clean:
437+
# generate_opal_keys binds host port 7002, which conflicts with a leftover
438+
# stack, and stale (transient) client ERRORs from the previous attempt's
439+
# broadcaster kills would otherwise trip check_no_error. The compose helper
440+
# uses --env-file .env, which exists after the first attempt's keygen.
441+
compose down --remove-orphans --volumes 2>/dev/null || true
442+
docker rm -f --wait opal-server-keygen 2>/dev/null || true
443+
rm -rf ./opal-tests-policy-repo ./temp-repo ./gitea-data ./git-repos 2>/dev/null || true
368444
done
369445

370446
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then

documentation/docs/getting-started/configuration.mdx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,11 +526,53 @@ The channel name for broadcasting messages.
526526

527527
For more information, see [running OPAL with Kafka](/tutorials/run_opal_with_kafka) and [running OPAL with Apache Pulsar](/tutorials/run_opal_with_pulsar).
528528

529-
#### OPAL_BROADCAST_CONN_LOSS_BUGFIX_EXPERIMENT_ENABLED
529+
#### OPAL_BROADCAST_RECONNECT_ENABLED
530530

531531
Default: `True`
532532

533-
Enable experimental fix for broadcast connection loss issues.
533+
Reconnect the broadcaster reader on a backbone disconnect instead of dropping all client connections. Set to `False` to revert to the legacy (non-reconnecting) broadcaster.
534+
535+
#### OPAL_BROADCAST_RECONNECT_MAX_RETRIES
536+
537+
Default: `0`
538+
539+
Maximum consecutive broadcaster reconnect attempts before giving up and letting the worker restart (`0` = retry forever).
540+
541+
#### OPAL_BROADCAST_RECONNECT_BACKOFF_MIN_SECONDS
542+
543+
Default: `0.5`
544+
545+
Minimum backoff in seconds between broadcaster reconnect attempts.
546+
547+
#### OPAL_BROADCAST_RECONNECT_BACKOFF_MAX_SECONDS
548+
549+
Default: `30.0`
550+
551+
Maximum backoff in seconds between broadcaster reconnect attempts.
552+
553+
#### OPAL_BROADCAST_REPLAY_BUFFER_SIZE
554+
555+
Default: `10000`
556+
557+
Maximum number of outbound broadcasts buffered while the backbone is down and replayed on reconnect (`0` disables buffering).
558+
559+
#### OPAL_BROADCAST_RESYNC_ON_RECONNECT
560+
561+
Default: `True`
562+
563+
After a backbone gap that may have lost updates, force this worker's connected clients to reconnect so they re-fetch full policy + data state (guarantees cross-instance consistency).
564+
565+
#### OPAL_BROADCAST_RESYNC_SETTLE_SECONDS
566+
567+
Default: `2.0`
568+
569+
Grace period after a broadcaster reconnect before replaying buffered broadcasts and resyncing clients, to let peer servers re-subscribe.
570+
571+
#### OPAL_BROADCAST_HEALTHCHECK_ENABLED
572+
573+
Default: `True`
574+
575+
Make `/healthcheck` reflect the broadcaster reader's health, so a Kubernetes readiness/liveness probe can route away from or restart a worker whose reader is wedged while clients depend on it.
534576

535577
#### OPAL_BROADCAST_KEEPALIVE_INTERVAL
536578

documentation/docs/getting-started/running-opal/run-opal-server/broadcast-interface.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,27 @@ This is how you define the number of workers (pay attention: this env var is not
5959
| Env Var Name | Function |
6060
| :------------------ | :--------------------------------------------------------------- |
6161
| UVICORN_NUM_WORKERS | the number of workers in a single container (example value: `4`) |
62+
63+
#### 4) Broadcaster reconnection (resilience)
64+
65+
If the broadcast backbone (Postgres/Redis/Kafka) briefly drops — for example during a managed-database failover or restart — OPAL servers reconnect to it automatically with bounded exponential backoff, instead of dropping their connected clients. This is enabled by default; the following **server-side** env vars (all prefixed with `OPAL_`) tune it:
66+
67+
| Env Var Name | Default | Function |
68+
| :------------------------------------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
69+
| OPAL_BROADCAST_RECONNECT_ENABLED | `true` | Reconnect the broadcaster reader on a backbone disconnect instead of dropping all client connections. Set to `false` for the legacy behavior. |
70+
| OPAL_BROADCAST_RECONNECT_MAX_RETRIES | `0` | Maximum consecutive reconnect attempts before giving up and letting the worker restart. `0` means retry forever. |
71+
| OPAL_BROADCAST_RECONNECT_BACKOFF_MIN_SECONDS | `0.5` | Minimum backoff (seconds) between reconnect attempts. |
72+
| OPAL_BROADCAST_RECONNECT_BACKOFF_MAX_SECONDS | `30` | Maximum backoff (seconds) between reconnect attempts. |
73+
| OPAL_BROADCAST_REPLAY_BUFFER_SIZE | `10000` | Max number of outbound broadcasts buffered while the backbone is down and replayed on reconnect (`0` disables buffering). On overflow the oldest are dropped. |
74+
| OPAL_BROADCAST_RESYNC_ON_RECONNECT | `true` | After a backbone gap, force this worker's clients to reconnect so they re-fetch full policy + data state. Set to `false` to rely only on best-effort replay. |
75+
| OPAL_BROADCAST_RESYNC_SETTLE_SECONDS | `2` | Grace period after a reconnect before replaying buffered broadcasts and resyncing clients, to let peer servers re-subscribe. |
76+
| OPAL_BROADCAST_HEALTHCHECK_ENABLED | `true` | Make `/healthcheck` reflect the broadcaster reader's health: it returns `503` (instead of `200`) when the reader is wedged while clients depend on it, so a k8s readiness/liveness probe can route away from or restart the worker. A normal transient reconnect still reads healthy. Set to `false` to keep `/healthcheck` always `200`. |
77+
78+
Consistency across the outage is handled in two layers. While the backbone is unreachable, client websocket connections are kept alive but cross-server fan-out is paused. On reconnect:
79+
80+
- **Replay buffer** — broadcasts that failed to reach the backbone during the outage are replayed, so peer servers that have re-subscribed catch up without a client refetch. This is best-effort: the backbone keeps no replay of its own, so a peer that is slow to re-subscribe may miss a replayed message.
81+
- **Resync** (the guarantee) — each server forces its own clients to reconnect and re-fetch the full policy/data state. Because every server experienced the same gap, every server reconciles its own clients and the fleet converges to current truth. Updates missed during the gap are therefore reconciled even if the replay did not reach a peer in time.
82+
83+
**Scope of the resync guarantee.** The resync re-fetch covers the policy and the **configured data sources** (those in `OPAL_DATA_CONFIG_SOURCES` / the server's data-source config) — clients fully refetch them. Runtime-published **incremental** updates — for example a `POST /data/config` that carries an inline `data` payload or a one-off fetch URL that is not part of the configured sources — are **not** part of that full refetch, so they are recoverable **only** through the best-effort replay buffer; if their replay is dropped (buffer overflow, or a peer that did not re-subscribe in time) the update is lost and not reconciled by the resync.
84+
85+
**Replay ordering.** Cross-worker replay ordering is not enforced, so a buffered update can be delivered after a newer live update (this self-heals for fetch-URL entries, which re-fetch current state, but a stale value can win for inline-`data` entries that carry the value directly).

packages/opal-server/opal_server/config.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,56 @@ class OpalServerConfig(Confi):
5252
"EventNotifier",
5353
description="The name to be used for segmentation in the backbone pub/sub",
5454
)
55-
BROADCAST_CONN_LOSS_BUGFIX_EXPERIMENT_ENABLED = confi.bool(
56-
"BROADCAST_CONN_LOSS_BUGFIX_EXPERIMENT_ENABLED",
55+
BROADCAST_RECONNECT_ENABLED = confi.bool(
56+
"BROADCAST_RECONNECT_ENABLED",
5757
True,
58-
description="Enable experimental bugfix for broadcast connection loss",
58+
description="Reconnect the broadcaster reader on a backbone disconnect instead "
59+
"of dropping all client connections. Set to False to revert to the legacy "
60+
"(non-reconnecting) broadcaster.",
61+
)
62+
BROADCAST_RECONNECT_MAX_RETRIES = confi.int(
63+
"BROADCAST_RECONNECT_MAX_RETRIES",
64+
0,
65+
description="Maximum consecutive broadcaster reconnect attempts before giving "
66+
"up and letting the worker restart (0 = retry forever).",
67+
)
68+
BROADCAST_RECONNECT_BACKOFF_MIN_SECONDS = confi.float(
69+
"BROADCAST_RECONNECT_BACKOFF_MIN_SECONDS",
70+
0.5,
71+
description="Minimum backoff in seconds between broadcaster reconnect attempts.",
72+
)
73+
BROADCAST_RECONNECT_BACKOFF_MAX_SECONDS = confi.float(
74+
"BROADCAST_RECONNECT_BACKOFF_MAX_SECONDS",
75+
30.0,
76+
description="Maximum backoff in seconds between broadcaster reconnect attempts.",
77+
)
78+
BROADCAST_REPLAY_BUFFER_SIZE = confi.int(
79+
"BROADCAST_REPLAY_BUFFER_SIZE",
80+
10000,
81+
description="Max number of outbound broadcasts buffered while the backbone is "
82+
"down and replayed on reconnect (0 disables buffering). On overflow the oldest "
83+
"buffered broadcasts are dropped; the resync on reconnect still reconciles clients.",
84+
)
85+
BROADCAST_RESYNC_ON_RECONNECT = confi.bool(
86+
"BROADCAST_RESYNC_ON_RECONNECT",
87+
True,
88+
description="After a backbone gap that may have lost updates, force this "
89+
"worker's connected clients to reconnect so they re-fetch full policy + data "
90+
"state (guarantees cross-instance consistency).",
91+
)
92+
BROADCAST_RESYNC_SETTLE_SECONDS = confi.float(
93+
"BROADCAST_RESYNC_SETTLE_SECONDS",
94+
2.0,
95+
description="Grace period after a broadcaster reconnect before replaying "
96+
"buffered broadcasts and resyncing clients, to let peer servers re-subscribe.",
97+
)
98+
BROADCAST_HEALTHCHECK_ENABLED = confi.bool(
99+
"BROADCAST_HEALTHCHECK_ENABLED",
100+
True,
101+
description="Make /healthcheck reflect the broadcaster reader's health so a "
102+
"k8s readiness/liveness probe can route away from or restart a worker whose "
103+
"reader is wedged while clients depend on it. Set to False to revert "
104+
"/healthcheck to always returning ok.",
59105
)
60106

61107
# server security

0 commit comments

Comments
 (0)