Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.sh text eol=lf
ci/redis-sentinel/docker-compose.yml text eol=lf
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,36 @@ jobs:
TSK_TEST_RECEIVER_PG_URL_B: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5433/tsk_test
TSK_TEST_CONTROL_PG_URL: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5434/tsk_test
TSK_TEST_REDIS_URL: redis://127.0.0.1:6379
# (PR2c acceptance) child-process SIGKILL/restart matrix over the FULL control cutover
# (bind->advance->import->ready->activate): before-commit SIGKILL rolls back (no torn state),
# after-commit SIGKILL resumes idempotently to ACTIVE. Three INDEPENDENT PG16 + real Redis.
# Real OS-level process kills. THROWS if any URL is unset. Contributes to closing #10.
- run: npm run test:sigkill
env:
TSK_TEST_SOURCE_PG_URL_A: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5432/tsk_test
TSK_TEST_RECEIVER_PG_URL_B: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5433/tsk_test
TSK_TEST_CONTROL_PG_URL: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5434/tsk_test
TSK_TEST_REDIS_URL: redis://127.0.0.1:6379
# (PR2c acceptance) GOVERNED B source-authority activation: after ACTIVE, control.activateSource()
# mints B's unforgeable guard-signed lease at the promoted epoch; B installs it, seeds (N, head@N),
# and ORIGINATES sequence N+1 through the fenced outbox while the OLD A (revoked, prior epoch) is
# DENIED. Three INDEPENDENT PG16 + real Redis. Contributes to closing #10.
- run: npm run test:b-activation
env:
TSK_TEST_SOURCE_PG_URL_A: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5432/tsk_test
TSK_TEST_RECEIVER_PG_URL_B: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5433/tsk_test
TSK_TEST_CONTROL_PG_URL: postgresql://tsk_test:tsk-test-only-password@127.0.0.1:5434/tsk_test
TSK_TEST_REDIS_URL: redis://127.0.0.1:6379
# (PR2c acceptance) REAL Redis Sentinel/quorum: 1 master + 2 REPLICAS + 3 sentinels (quorum 2),
# AOF, min-replicas-to-write. A real master CRASH → automatic promotion; claim() ENFORCES a WAIT
# replica-quorum ACK so the fence SURVIVES (RPO=0), stays monotonic, RTO measured. run.sh brings up
# the topology (docker compose) and runs the drill on the host (ioredis natMap over published ports).
- run: npm run test:sentinel
# (PR2c acceptance) LIVE split-brain PARTITION: the old master is isolated ALIVE (docker network
# disconnect); it REFUSES writes (min-replicas-to-write → NOREPLICAS, no split-brain progress) while
# the surviving quorum promotes a new writable+monotonic master (fence intact, RPO=0); on HEAL the old
# master is demoted to a replica and reconciles. Per-fault RPO/RTO. Closes the Redis-HA gate for #10.
- run: npm run test:partition
- run: npm run test:pack
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ debug-e2e.mts

# FileTumblerStore persistent data — lives in ~/.tsk, never in repo
demo/tsk-maps.json

# PR2c SIGKILL drill handoff (test key material — never commit)
tsk-sigkill-handoff.json
78 changes: 78 additions & 0 deletions ci/redis-sentinel/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Real Redis Sentinel/quorum topology for the PR2c acceptance drills (#10):
# 1 master + 2 REPLICAS (3 data nodes) + 3 SENTINELS (quorum 2), AOF everysec.
# min-replicas-to-write 1 + min-replicas-max-lag → a master that loses its replica quorum REFUSES
# writes (the split-brain guard). STATIC IPs so sentinels monitor by fixed IP (survives a crashed
# master container exiting) AND PUBLISHED ports so the host drills reach every node directly; the
# sentinel-backed client uses ioredis natMap (internal IP:6379 → 127.0.0.1:published) to auto-follow
# failover from the host. Mechanism-only on one host (processes, not physical failure domains).
networks:
tsknet:
driver: bridge
ipam:
config:
- subnet: 172.28.7.0/24
x-redis-data: &redis-data
image: redis:7.4.5-alpine@sha256:bb186d083732f669da90be8b0f975a37812b15e913465bb14d845db72a4e3e08
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 1s
timeout: 2s
retries: 40
services:
redis-master:
<<: *redis-data
command: >-
redis-server --appendonly yes --appendfsync everysec --save ""
--repl-diskless-sync yes --min-replicas-to-write 1 --min-replicas-max-lag 10
restart: "no"
ports: ["6390:6379"]
networks:
tsknet: { ipv4_address: 172.28.7.10 }
redis-replica-1:
<<: *redis-data
command: >-
redis-server --appendonly yes --appendfsync everysec --save ""
--replicaof 172.28.7.10 6379 --replica-read-only yes --min-replicas-to-write 1 --min-replicas-max-lag 10
depends_on:
redis-master: { condition: service_healthy }
ports: ["6391:6379"]
networks:
tsknet: { ipv4_address: 172.28.7.11 }
redis-replica-2:
<<: *redis-data
command: >-
redis-server --appendonly yes --appendfsync everysec --save ""
--replicaof 172.28.7.10 6379 --replica-read-only yes --min-replicas-to-write 1 --min-replicas-max-lag 10
depends_on:
redis-master: { condition: service_healthy }
ports: ["6392:6379"]
networks:
tsknet: { ipv4_address: 172.28.7.12 }
sentinel-1: &sentinel
image: redis:7.4.5-alpine@sha256:bb186d083732f669da90be8b0f975a37812b15e913465bb14d845db72a4e3e08
depends_on:
redis-master: { condition: service_healthy }
redis-replica-1: { condition: service_healthy }
redis-replica-2: { condition: service_healthy }
entrypoint:
- sh
- -c
- |
cat > /tmp/sentinel.conf <<EOF
port 26379
sentinel monitor tskmaster 172.28.7.10 6379 2
sentinel down-after-milliseconds tskmaster 3000
sentinel failover-timeout tskmaster 8000
sentinel parallel-syncs tskmaster 1
EOF
exec redis-sentinel /tmp/sentinel.conf
ports: ["26379:26379"]
networks: [tsknet]
sentinel-2:
<<: *sentinel
ports: ["26380:26379"]
networks: [tsknet]
sentinel-3:
<<: *sentinel
ports: ["26381:26379"]
networks: [tsknet]
35 changes: 35 additions & 0 deletions ci/redis-sentinel/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Bring up the Redis Sentinel topology (1 master + 2 replicas + 3 sentinels), then run ONE PR2c drill
# ON THE HOST (so it can drive `docker network disconnect` for a live partition, and use ioredis natMap
# to follow Sentinel failover through the published ports). A fresh topology per drill invocation.
# Usage: run.sh <drill.mts>
set -euo pipefail
DRILL="${1:?usage: run.sh <drill.mts>}"
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)"
compose() { (cd "$DIR" && docker compose -p tsk-sentinel "$@"); }
cleanup() { compose down -v --remove-orphans >/dev/null 2>&1 || true; }
trap cleanup EXIT
cleanup
compose up -d

echo "waiting for sentinels to see master + 2 replicas..."
ok=0
for _ in $(seq 1 60); do
n="$(docker exec tsk-sentinel-sentinel-1-1 redis-cli -p 26379 sentinel master tskmaster 2>/dev/null | awk '/num-slaves/{getline; print}' | tr -d '\r' || echo 0)"
if [ "${n:-0}" -ge 2 ]; then ok=1; break; fi
sleep 1
done
[ "$ok" = 1 ] || { echo "topology did not converge (num-slaves<2)"; compose ps; exit 1; }
# ensure both replicas are CAUGHT UP (min-replicas-to-write would otherwise reject the first durable claim).
docker exec tsk-sentinel-redis-master-1 redis-cli WAIT 2 10000 >/dev/null || true

export TSK_TEST_SENTINELS="127.0.0.1:26379,127.0.0.1:26380,127.0.0.1:26381"
export TSK_TEST_SENTINEL_MASTER="tskmaster"
export TSK_SENTINEL_NATMAP="172.28.7.10:6379=127.0.0.1:6390,172.28.7.11:6379=127.0.0.1:6391,172.28.7.12:6379=127.0.0.1:6392"
export TSK_SENTINEL_NETWORK="tsk-sentinel_tsknet"
export TSK_SENTINEL_MASTER_CONTAINER="tsk-sentinel-redis-master-1"
export TSK_SENTINEL_MASTER_PORT="6390"

cd "$ROOT"
node --import tsx "$DRILL"
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"test:source-export": "npm run build -w packages/server && npx tsx tsk-source-export-drill.mts",
"test:receiver": "npm run build -w packages/server && npx tsx tsk-receiver-drill.mts",
"test:control-cutover": "npm run build -w packages/server && npx tsx tsk-control-cutover-drill.mts",
"test:sigkill": "npm run build -w packages/server && npx tsx tsk-cutover-sigkill-drill.mts",
"test:b-activation": "npm run build -w packages/server && npx tsx tsk-b-activation-drill.mts",
"test:sentinel": "npm run build -w packages/server && bash ci/redis-sentinel/run.sh tsk-ha-sentinel-drill.mts",
"test:partition": "npm run build -w packages/server && bash ci/redis-sentinel/run.sh tsk-ha-partition-drill.mts",
"test:two-node": "npm run build -w packages/server && npx tsx tsk-two-node-drill.mts",
"test:pack": "npm run build && npx tsx package-boundary-suite.mts && npm pack --dry-run --workspaces",
"test:bpc-compat": "npm run build && npx tsx bpc-compatibility-suite.mts",
Expand Down
Loading
Loading