deer-flow/scripts/dev_multi_instance.sh
Nan Gao e8c7df9042
feat(channels): arbitrate IM transports with a per-provider leader lease (#6843)
* feat(channels): arbitrate IM transports with a per-provider leader lease

Every Gateway instance connected every enabled IM platform with the same
bot credentials, so channels could not run on more than one instance
(Telegram 409 storms, Discord answering twice, scattered per-process
state). channels.leader_lease was reserved by #6746; this implements it.

- channel_leases table (migration 0041): one row per provider. Acquire is
  one dialect-native upsert decided against the database clock and bumps
  a fencing epoch; renew extends only a live lease at the holder's epoch;
  release sets the expiry to 0 so a peer takes over on its next tick.
- app/channels/leadership.py: the holder's local monotonic deadline
  (send time of the last confirmed renewal + lease_seconds); holds() turns
  false one renewal interval early, so the transport stops at least
  grace_seconds before a peer may connect. DB calls are bounded.
- ChannelService: start fence in _start_channel (no DB call) behind every
  start path; fixed-rate supervisor renews/acquires and runs transport
  starts/stops as per-provider tasks; takeover reloads the provider's
  saved config; failed starts back off; shutdown releases only leases
  whose transport stopped. get_status reports leader/leader_here and the
  holder's transport state on every instance.
- Notification worker leaves rows of providers another instance holds.
- Gate: the enabled=false refusal now recommends auto/true; an explicit
  true with database.backend memory is refused when a channel is enabled.
- Docs, Helm, deployment guides, AGENTS.md and CHANGELOG updated.

* docs(changelog): link the channel leader lease entry to #6843

* fix(channels): see peer-held leases for providers absent from local config

Review on #6843: a provider connected from the web UI on a peer never
enters this instance's channel config, so the notification worker here
still claimed its rows (and parked them for 900s) and GET /api/channels/
reported no leader for it. Every supervision round, and startup, now also
reads the whole lease table once (bounded), and notification exclusions
and status use those observations: a provider whose live lease a peer
holds is left to it even without local config.

Running that read concurrently with renewals exposed that, through
SQLAlchemy's aiosqlite adapter, an UPDATE ... RETURNING racing a read on
another connection can hand back one of its own bind values instead of
the column (stored rows stay correct; plain sqlite3 and raw aiosqlite do
not show it). A lease must never take such a row for a confirmed
renewal, so acquire/renew/release now decide by affected-row count and
acquire reads its epoch back in the same transaction.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 13:51:42 +08:00

675 lines
26 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# dev_multi_instance.sh — run two Gateways as one local multi-instance cluster.
#
# Starts throwaway Postgres and Redis containers, renders a multi-instance
# config.yaml from your own config (or config.example.yaml), and runs Gateway A
# and Gateway B on one shared DEER_FLOW_HOME with shared auth secrets, plus an
# optional round-robin nginx in front of both. Run with --help for commands.
#
# Config rendering, secrets, the nginx config and the checks live in
# dev_multi_instance.py (unit-tested); this file owns containers and processes.
# Everything lives under DEERFLOW_MI_STATE_DIR so it never collides with
# `make dev` state in backend/.deer-flow.
set -euo pipefail
REPO_ROOT="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd -P)"
BACKEND_DIR="$REPO_ROOT/backend"
HELPER="$REPO_ROOT/scripts/dev_multi_instance.py"
STATE_DIR="${DEERFLOW_MI_STATE_DIR:-$REPO_ROOT/.deer-flow/multi-instance}"
# Gateways run from backend/, so every path handed to them must be absolute.
case "$STATE_DIR" in
/*) ;;
*) STATE_DIR="$PWD/$STATE_DIR" ;;
esac
GATEWAY_A_PORT="${DEERFLOW_MI_GATEWAY_A_PORT:-8001}"
GATEWAY_B_PORT="${DEERFLOW_MI_GATEWAY_B_PORT:-8011}"
NGINX_PORT="${DEERFLOW_MI_NGINX_PORT:-2027}"
FRONTEND_PORT="${DEERFLOW_MI_FRONTEND_PORT:-3000}"
PG_PORT="${DEERFLOW_MI_POSTGRES_PORT:-55432}"
REDIS_PORT="${DEERFLOW_MI_REDIS_PORT:-56379}"
PG_CONTAINER="${DEERFLOW_MI_POSTGRES_CONTAINER:-deerflow-mi-postgres}"
REDIS_CONTAINER="${DEERFLOW_MI_REDIS_CONTAINER:-deerflow-mi-redis}"
PG_IMAGE="${DEERFLOW_MI_POSTGRES_IMAGE:-postgres:17-alpine}"
REDIS_IMAGE="${DEERFLOW_MI_REDIS_IMAGE:-redis:7-alpine}"
HEALTH_TIMEOUT="${DEERFLOW_MI_HEALTH_TIMEOUT:-180}"
LABEL_KEY="deerflow.harness"
LABEL_VALUE="dev-multi-instance"
# Container names are global, so each container also records the state dir
# that created it; only that state dir may remove it.
STATE_LABEL_KEY="deerflow.harness.state-dir"
STATE_MARKER=".dev-multi-instance"
RUN_DIR="$STATE_DIR/run"
LOG_DIR="$STATE_DIR/logs"
CONFIG_OUT="$STATE_DIR/config.yaml"
EXTENSIONS_OUT="$STATE_DIR/extensions_config.json"
SECRETS_FILE="$STATE_DIR/secrets.env"
usage() {
cat <<EOF
Usage: scripts/dev_multi_instance.sh <command> [options]
Run two DeerFlow Gateways (A and B) on shared Postgres, Redis and
DEER_FLOW_HOME: the topology that deployment.multi_instance: true targets.
Commands:
up [--skip-install] [--no-nginx] Start containers, render the config, start A, then B, then nginx
down Stop everything; delete the containers and harness data (logs are kept)
status Show containers, Gateways and nginx
logs [a|b|nginx|all] [-f] Print the last 100 log lines (-f follows)
check [--with-llm] Run the cross-instance checks against the running pair
stop <a|b> [--kill] Stop one Gateway (SIGTERM; --kill sends SIGKILL to simulate a crash)
start <a|b> Start a stopped Gateway again
restart <a|b> stop + start
help Show this help
Defaults:
Gateway A http://127.0.0.1:$GATEWAY_A_PORT Gateway B http://127.0.0.1:$GATEWAY_B_PORT
nginx http://localhost:$NGINX_PORT (round-robin /api over A and B; / goes to a frontend on :$FRONTEND_PORT)
Postgres 127.0.0.1:$PG_PORT ($PG_CONTAINER) Redis 127.0.0.1:$REDIS_PORT ($REDIS_CONTAINER)
State $STATE_DIR
Environment overrides:
DEERFLOW_MI_BASE_CONFIG base config (default: \$DEER_FLOW_CONFIG_PATH, config.yaml,
backend/config.yaml, then config.example.yaml)
DEERFLOW_MI_STATE_DIR state directory
DEERFLOW_MI_GATEWAY_A_PORT, DEERFLOW_MI_GATEWAY_B_PORT, DEERFLOW_MI_NGINX_PORT,
DEERFLOW_MI_FRONTEND_PORT, DEERFLOW_MI_POSTGRES_PORT, DEERFLOW_MI_REDIS_PORT
DEERFLOW_MI_POSTGRES_CONTAINER, DEERFLOW_MI_REDIS_CONTAINER,
DEERFLOW_MI_POSTGRES_IMAGE, DEERFLOW_MI_REDIS_IMAGE
DEERFLOW_MI_KEEP_CHANNELS=1 keep IM channels enabled (the leader lease gives each bot to one Gateway)
DEERFLOW_MI_HEALTH_TIMEOUT seconds to wait for each Gateway's /health (default 180)
'make stop' and 'make dev' reclaim port 8001 and this checkout's Gateways, so
do not run them while the harness is up.
EOF
}
die() {
echo "✗ $*" >&2
exit 1
}
upper() {
printf '%s' "$1" | tr '[:lower:]' '[:upper:]'
}
port_in_use() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN -t >/dev/null 2>&1
}
pid_file() {
printf '%s/%s.pid' "$RUN_DIR" "$1"
}
# Print the pid recorded for $1 when that process is still the one we started
# ($2 is a fragment of its command line), so a reused pid is never signalled.
live_pid() {
local pid command
pid="$(cat "$(pid_file "$1")" 2>/dev/null || true)"
[ -n "$pid" ] || return 1
kill -0 "$pid" 2>/dev/null || return 1
command="$(ps -p "$pid" -o command= 2>/dev/null || true)"
case "$command" in
*"$2"*) printf '%s' "$pid" ;;
*) return 1 ;;
esac
}
gateway_pid() {
live_pid "gateway-$1" "app.gateway.app:app"
}
nginx_pid() {
live_pid nginx "nginx"
}
gateway_port() {
case "$1" in
a) printf '%s' "$GATEWAY_A_PORT" ;;
b) printf '%s' "$GATEWAY_B_PORT" ;;
*) die "Unknown Gateway '$1' (expected a or b)" ;;
esac
}
# Resolve the backend interpreter once (in this shell, not a subshell) and run
# it directly: `uv run` would sit between us and uvicorn, so the recorded pid
# would not be the process that serves (SIGKILL would orphan the server).
ensure_python() {
if [ -z "${PYTHON_BIN:-}" ]; then
PYTHON_BIN="$(uv run --no-sync --project "$BACKEND_DIR" python -c 'import sys; print(sys.executable)')" ||
die "Cannot run the backend environment. Run 'make install' (or 'cd backend && uv sync') first."
fi
}
run_helper() {
ensure_python
"$PYTHON_BIN" "$HELPER" "$@"
}
load_secrets() {
[ -f "$SECRETS_FILE" ] || die "Missing $SECRETS_FILE; run 'up' first."
set -a
# shellcheck disable=SC1090
. "$SECRETS_FILE"
set +a
}
# ── State directory ──────────────────────────────────────────────────────────
ensure_state_dir() {
if [ -d "$STATE_DIR" ] && [ ! -f "$STATE_DIR/$STATE_MARKER" ] && [ -n "$(ls -A "$STATE_DIR" 2>/dev/null)" ]; then
die "$STATE_DIR is not empty and was not created by this harness; set DEERFLOW_MI_STATE_DIR to another directory."
fi
mkdir -p "$STATE_DIR" "$RUN_DIR" "$LOG_DIR"
touch "$STATE_DIR/$STATE_MARKER"
}
# Delete everything the harness created except logs. Guarded by the marker so
# a mistyped DEERFLOW_MI_STATE_DIR can never point this at someone's data.
wipe_runtime_data() {
[ -f "$STATE_DIR/$STATE_MARKER" ] || return 0
find "$STATE_DIR" -mindepth 1 -maxdepth 1 ! -name logs ! -name "$STATE_MARKER" -exec rm -rf {} +
}
# ── Containers ───────────────────────────────────────────────────────────────
require_docker() {
command -v docker >/dev/null 2>&1 || die "docker is not installed."
docker info >/dev/null 2>&1 || die "Docker is not running. Start Docker Desktop (or the daemon) and retry."
}
container_exists() {
docker inspect "$1" >/dev/null 2>&1
}
container_label() {
docker inspect -f "{{ index .Config.Labels \"$2\" }}" "$1" 2>/dev/null || true
}
# Remove container $1, with its anonymous volumes (both images declare a
# VOLUME), when this state dir created it. A container from another harness
# state dir, or from outside the harness, is never touched: returns 1.
remove_container() {
local owner
container_exists "$1" || return 0
if [ "$(container_label "$1" "$LABEL_KEY")" != "$LABEL_VALUE" ]; then
echo " ! container $1 was not created by this harness; leaving it alone" >&2
return 1
fi
owner="$(container_label "$1" "$STATE_LABEL_KEY")"
if [ "$owner" != "$STATE_DIR" ]; then
echo " ! container $1 belongs to the harness state dir ${owner:-<unrecorded>}; leaving it alone" >&2
echo " (take that harness down from there, or 'docker rm -f -v $1' if it is stale)" >&2
return 1
fi
docker rm -f -v "$1" >/dev/null
echo "✓ Removed container $1 and its volumes"
}
wait_until() {
local timeout=$1 waited=0
shift
until "$@" >/dev/null 2>&1; do
[ "$waited" -lt "$timeout" ] || return 1
sleep 1
waited=$((waited + 1))
done
}
redis_pong() {
[ "$(docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null)" = "PONG" ]
}
start_containers() {
echo "Starting Postgres ($PG_IMAGE) on 127.0.0.1:$PG_PORT..."
POSTGRES_PASSWORD="$DEERFLOW_MI_POSTGRES_PASSWORD" docker run -d --name "$PG_CONTAINER" \
--label "$LABEL_KEY=$LABEL_VALUE" --label "$STATE_LABEL_KEY=$STATE_DIR" \
-e POSTGRES_USER=deerflow -e POSTGRES_DB=deerflow -e POSTGRES_PASSWORD \
-p "127.0.0.1:$PG_PORT:5432" "$PG_IMAGE" >/dev/null || return 1
echo "Starting Redis ($REDIS_IMAGE) on 127.0.0.1:$REDIS_PORT..."
docker run -d --name "$REDIS_CONTAINER" --label "$LABEL_KEY=$LABEL_VALUE" --label "$STATE_LABEL_KEY=$STATE_DIR" \
-p "127.0.0.1:$REDIS_PORT:6379" "$REDIS_IMAGE" >/dev/null || return 1
# TCP readiness: the image's init-time server listens on the Unix socket only.
if ! wait_until 90 docker exec "$PG_CONTAINER" pg_isready -h 127.0.0.1 -U deerflow -d deerflow; then
docker logs --tail 30 "$PG_CONTAINER" >&2 || true
echo "✗ Postgres did not become ready" >&2
return 1
fi
echo "✓ Postgres ready"
if ! wait_until 30 redis_pong; then
docker logs --tail 30 "$REDIS_CONTAINER" >&2 || true
echo "✗ Redis did not answer PING" >&2
return 1
fi
echo "✓ Redis ready"
}
# ── Gateways and nginx ───────────────────────────────────────────────────────
start_gateway() {
local name=$1 port log pid waited=0
port="$(gateway_port "$name")"
log="$LOG_DIR/gateway-$name.log"
if pid="$(gateway_pid "$name")"; then
echo "✗ Gateway $(upper "$name") is already running (pid $pid)." >&2
return 1
fi
if port_in_use "$port"; then
echo "✗ Port $port is in use; free it or set DEERFLOW_MI_GATEWAY_$(upper "$name")_PORT." >&2
return 1
fi
if [ ! -f "$CONFIG_OUT" ]; then
echo "✗ Missing $CONFIG_OUT; run 'up' first." >&2
return 1
fi
load_secrets
mkdir -p "$STATE_DIR/index/$name" "$STATE_DIR/home"
ensure_python
echo "Starting Gateway $(upper "$name") on 127.0.0.1:$port..."
{
echo ""
echo "===== $(date '+%Y-%m-%d %H:%M:%S') starting Gateway $(upper "$name") on 127.0.0.1:$port ====="
} >>"$log"
(
cd "$BACKEND_DIR"
# One worker per Gateway: the pid file must name the process that serves.
export DEER_FLOW_CONFIG_PATH="$CONFIG_OUT" \
DEER_FLOW_EXTENSIONS_CONFIG_PATH="$EXTENSIONS_OUT" \
DEER_FLOW_HOME="$STATE_DIR/home" \
DEER_FLOW_PROJECT_ROOT="$REPO_ROOT" \
DEERFLOW_MI_RETRIEVAL_INDEX_PATH="$STATE_DIR/index/$name" \
DEERFLOW_MI_DATABASE_URL="postgresql://deerflow:$DEERFLOW_MI_POSTGRES_PASSWORD@127.0.0.1:$PG_PORT/deerflow" \
DEERFLOW_MI_REDIS_URL="redis://127.0.0.1:$REDIS_PORT/0" \
DEER_FLOW_CHANNELS_LANGGRAPH_URL="http://127.0.0.1:$port/api" \
DEER_FLOW_CHANNELS_GATEWAY_URL="http://127.0.0.1:$port" \
GATEWAY_WORKERS=1 WEB_CONCURRENCY=1 PYTHONPATH=. \
AUTH_JWT_SECRET DEER_FLOW_INTERNAL_AUTH_TOKEN DEER_FLOW_CREDENTIALS_KEY
exec nohup "$PYTHON_BIN" -m uvicorn app.gateway.app:app --host 127.0.0.1 --port "$port" </dev/null
) >>"$log" 2>&1 &
pid=$!
# The Gateway outlives this script: drop it from the job table so stopping
# it later (teardown, stop/restart) prints no "Terminated" job notice.
disown "$pid" 2>/dev/null || true
echo "$pid" >"$(pid_file "gateway-$name")"
while ! curl -fsS --noproxy '*' -o /dev/null --max-time 2 "http://127.0.0.1:$port/health" 2>/dev/null; do
if ! kill -0 "$pid" 2>/dev/null; then
echo "✗ Gateway $(upper "$name") exited during startup. Last lines of $log:" >&2
tail -n 40 "$log" >&2
return 1
fi
if [ "$waited" -ge "$HEALTH_TIMEOUT" ]; then
echo "✗ Gateway $(upper "$name") did not answer /health within ${HEALTH_TIMEOUT}s. Last lines of $log:" >&2
tail -n 40 "$log" >&2
return 1
fi
sleep 1
waited=$((waited + 1))
done
echo "✓ Gateway $(upper "$name") ready on http://127.0.0.1:$port (pid $pid, ${waited}s)"
}
# stop_process NAME CMD_FRAGMENT SIGNAL
stop_process() {
local name=$1 fragment=$2 signal=${3:-TERM} pid waited=0
if pid="$(live_pid "$name" "$fragment")"; then
kill "-$signal" "$pid" 2>/dev/null || true
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge 30 ]; then
echo " ! $name (pid $pid) ignored SIG$signal for 30s; sending SIGKILL" >&2
kill -KILL "$pid" 2>/dev/null || true
break
fi
sleep 1
waited=$((waited + 1))
done
echo "✓ Stopped $name (pid $pid, SIG$signal)"
fi
rm -f "$(pid_file "$name")"
}
start_nginx() {
local conf="$STATE_DIR/nginx/nginx.conf" waited=0
if ! command -v nginx >/dev/null 2>&1; then
echo " nginx not found; skipping the round-robin front (Gateways are still reachable directly)"
return 0
fi
if port_in_use "$NGINX_PORT"; then
echo " ! port $NGINX_PORT is in use; skipping nginx (set DEERFLOW_MI_NGINX_PORT)" >&2
return 0
fi
mkdir -p "$STATE_DIR/nginx/temp"
run_helper nginx-conf --out "$conf" --state-dir "$STATE_DIR" --listen-port "$NGINX_PORT" \
--gateway-port "$GATEWAY_A_PORT" --gateway-port "$GATEWAY_B_PORT" --frontend-port "$FRONTEND_PORT" || return 1
echo "Starting nginx on 127.0.0.1:$NGINX_PORT..."
if ! nginx -e "$LOG_DIR/nginx-error.log" -p "$STATE_DIR/nginx" -c "$conf" >>"$LOG_DIR/nginx.log" 2>&1; then
tail -n 20 "$LOG_DIR/nginx.log" "$LOG_DIR/nginx-error.log" >&2 2>/dev/null || true
echo "✗ nginx failed to start" >&2
return 1
fi
until nginx_pid >/dev/null; do
[ "$waited" -lt 10 ] || { echo "✗ nginx wrote no pid file" >&2; return 1; }
sleep 1
waited=$((waited + 1))
done
echo "✓ nginx ready on http://localhost:$NGINX_PORT (pid $(nginx_pid))"
}
# Stop processes and remove containers; keep config and logs for inspection.
teardown() {
stop_process nginx "nginx" QUIT
stop_process gateway-b "app.gateway.app:app" TERM
stop_process gateway-a "app.gateway.app:app" TERM
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
remove_container "$PG_CONTAINER" || true
remove_container "$REDIS_CONTAINER" || true
else
echo " ! Docker is not reachable; containers $PG_CONTAINER / $REDIS_CONTAINER were not removed" >&2
fi
}
fail_up() {
echo "✗ $*" >&2
exit 1
}
# EXIT trap while `up` starts things: any exit before the banner (fail_up, die,
# set -e, Ctrl+C) stops what was started instead of leaving it running.
UP_IN_PROGRESS=false
on_up_exit() {
local status=$?
trap - EXIT INT TERM
if $UP_IN_PROGRESS; then
echo " Tearing down the partial start; logs stay in $LOG_DIR" >&2
teardown
[ "$status" -ne 0 ] || status=1
fi
exit "$status"
}
# ── Inputs ───────────────────────────────────────────────────────────────────
absolute_path() {
printf '%s/%s' "$(builtin cd "$(dirname "$1")" >/dev/null 2>&1 && pwd -P)" "$(basename "$1")"
}
resolve_base_config() {
local candidate
if [ -n "${DEERFLOW_MI_BASE_CONFIG:-}" ]; then
[ -f "$DEERFLOW_MI_BASE_CONFIG" ] || die "DEERFLOW_MI_BASE_CONFIG=$DEERFLOW_MI_BASE_CONFIG does not exist."
BASE_CONFIG="$(absolute_path "$DEERFLOW_MI_BASE_CONFIG")"
return 0
fi
for candidate in "${DEER_FLOW_CONFIG_PATH:-}" "$REPO_ROOT/config.yaml" "$REPO_ROOT/backend/config.yaml" "$REPO_ROOT/config.example.yaml"; do
[ -n "$candidate" ] && [ -f "$candidate" ] || continue
candidate="$(absolute_path "$candidate")"
[ "$candidate" != "$CONFIG_OUT" ] || continue
BASE_CONFIG="$candidate"
return 0
done
die "No base config found (config.yaml or config.example.yaml)."
}
copy_extensions_config() {
local candidate
for candidate in "${DEER_FLOW_EXTENSIONS_CONFIG_PATH:-}" "$REPO_ROOT/extensions_config.json" "$REPO_ROOT/backend/extensions_config.json"; do
[ -n "$candidate" ] && [ -f "$candidate" ] && [ "$candidate" != "$EXTENSIONS_OUT" ] || continue
cp "$candidate" "$EXTENSIONS_OUT"
chmod 600 "$EXTENSIONS_OUT"
echo " extensions: copied $candidate (Gateway MCP/skill edits go to the copy)"
return 0
done
printf '{\n "mcpServers": {},\n "skills": {}\n}\n' >"$EXTENSIONS_OUT"
echo " extensions: none found; using an empty extensions_config.json"
}
# Extras the base config needs (models, channels, ...) plus the two this
# topology always needs. The detector only emits validated extra names.
uv_extra_flags() {
local detected="" token previous="" flags="--extra postgres --extra redis"
if command -v python3 >/dev/null 2>&1; then
detected="$(DEER_FLOW_CONFIG_PATH="$BASE_CONFIG" python3 "$REPO_ROOT/scripts/detect_uv_extras.py" 2>/dev/null || true)"
fi
for token in $detected; do
if [ "$previous" = "--extra" ] && [ "$token" != postgres ] && [ "$token" != redis ]; then
flags="$flags --extra $token"
fi
previous="$token"
done
printf '%s' "$flags"
}
# ── Commands ─────────────────────────────────────────────────────────────────
cmd_up() {
local skip_install=false with_nginx=true arg port extras
for arg in "$@"; do
case "$arg" in
--skip-install) skip_install=true ;;
--no-nginx) with_nginx=false ;;
*) die "Unknown option for up: $arg" ;;
esac
done
require_docker
command -v uv >/dev/null 2>&1 || die "uv is not installed."
command -v curl >/dev/null 2>&1 || die "curl is not installed."
command -v lsof >/dev/null 2>&1 || die "lsof is not installed."
if gateway_pid a >/dev/null || gateway_pid b >/dev/null || nginx_pid >/dev/null; then
die "The harness is already running; run 'down' first (or 'status')."
fi
ensure_state_dir
resolve_base_config
# A previous session of this state dir that was not taken down: clear it
# before the port checks. Containers of any other owner are refused.
remove_container "$PG_CONTAINER" || die "Container name $PG_CONTAINER is taken; set DEERFLOW_MI_POSTGRES_CONTAINER (and the ports) to run a second harness."
remove_container "$REDIS_CONTAINER" || die "Container name $REDIS_CONTAINER is taken; set DEERFLOW_MI_REDIS_CONTAINER (and the ports) to run a second harness."
for port in "$GATEWAY_A_PORT" "$GATEWAY_B_PORT" "$PG_PORT" "$REDIS_PORT"; do
if port_in_use "$port"; then
die "Port $port is in use. Stop whatever holds it ('make stop' for make dev) or override the port (see --help)."
fi
done
wipe_runtime_data
mkdir -p "$RUN_DIR" "$LOG_DIR" "$STATE_DIR/home" "$STATE_DIR/index"
: >"$LOG_DIR/gateway-a.log"
: >"$LOG_DIR/gateway-b.log"
rm -f "$LOG_DIR"/nginx*.log
echo "Base config: $BASE_CONFIG"
if ! $skip_install; then
extras="$(uv_extra_flags)"
echo "Syncing backend dependencies ($extras)..."
# Intentionally unquoted: splats validated `--extra NAME` pairs.
# shellcheck disable=SC2086
(cd "$BACKEND_DIR" && uv sync --locked --quiet --all-packages $extras) || die "Backend dependency sync failed."
fi
run_helper secrets --out "$SECRETS_FILE" || die "Could not create $SECRETS_FILE."
load_secrets
echo "Rendering $CONFIG_OUT..."
local keep_channels=""
if [ "${DEERFLOW_MI_KEEP_CHANNELS:-}" = "1" ]; then
keep_channels="--keep-channels"
fi
# The URLs stay $VAR references so the file holds no harness secret; each
# Gateway process exports them (see start_gateway).
# shellcheck disable=SC2016
run_helper render-config --base "$BASE_CONFIG" --out "$CONFIG_OUT" --state-dir "$STATE_DIR" \
--database-url '$DEERFLOW_MI_DATABASE_URL' --redis-url '$DEERFLOW_MI_REDIS_URL' $keep_channels ||
die "Could not render the multi-instance config."
copy_extensions_config
UP_IN_PROGRESS=true
trap on_up_exit EXIT
trap 'echo ""; fail_up "Interrupted"' INT TERM
start_containers || fail_up "Containers failed to start."
# Sequential cold start: Alembic migrations are advisory-locked, but the
# LangGraph checkpointer/store setup() is not, so B starts after A is up.
start_gateway a || fail_up "Gateway A failed to start."
start_gateway b || fail_up "Gateway B failed to start."
if $with_nginx; then
start_nginx || fail_up "nginx failed to start."
fi
UP_IN_PROGRESS=false
trap - EXIT INT TERM
echo ""
echo "=========================================="
echo " ✓ Multi-instance harness is up"
echo "=========================================="
echo " Gateway A http://127.0.0.1:$GATEWAY_A_PORT (log $LOG_DIR/gateway-a.log)"
echo " Gateway B http://127.0.0.1:$GATEWAY_B_PORT (log $LOG_DIR/gateway-b.log)"
if nginx_pid >/dev/null; then
echo " nginx http://localhost:$NGINX_PORT (round-robin; start the frontend on :$FRONTEND_PORT for the UI)"
fi
echo " Postgres 127.0.0.1:$PG_PORT ($PG_CONTAINER, user/db deerflow, password in $SECRETS_FILE)"
echo " Redis 127.0.0.1:$REDIS_PORT ($REDIS_CONTAINER)"
echo " Shared home $STATE_DIR/home"
echo " Config $CONFIG_OUT"
echo ""
echo " Next: scripts/dev_multi_instance.sh check"
echo " scripts/dev_multi_instance.sh stop b --kill (crash B), then: start b"
echo " scripts/dev_multi_instance.sh down"
}
cmd_down() {
[ $# -eq 0 ] || die "down takes no options"
teardown
wipe_runtime_data
echo "✓ Harness is down (logs kept in $LOG_DIR)"
}
cmd_status() {
local name pid port code container
echo "State dir: $STATE_DIR"
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
for container in "$PG_CONTAINER" "$REDIS_CONTAINER"; do
if container_exists "$container"; then
echo " $container: $(docker inspect -f '{{.State.Status}}' "$container")"
else
echo " $container: absent"
fi
done
else
echo " containers: Docker is not reachable"
fi
for name in a b; do
port="$(gateway_port "$name")"
if pid="$(gateway_pid "$name")"; then
code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' --max-time 3 "http://127.0.0.1:$port/health/ready" || true)"
echo " Gateway $(upper "$name"): running (pid $pid) http://127.0.0.1:$port, /health/ready $code"
else
echo " Gateway $(upper "$name"): stopped"
fi
done
if pid="$(nginx_pid)"; then
echo " nginx: running (pid $pid) http://localhost:$NGINX_PORT"
else
echo " nginx: stopped"
fi
}
cmd_logs() {
local follow=false arg files=""
for arg in "$@"; do
case "$arg" in
a | b) files="$files gateway-$arg.log" ;;
nginx) files="$files nginx-error.log nginx-access.log" ;;
all) files="$files gateway-a.log gateway-b.log" ;;
-f | --follow) follow=true ;;
*) die "Unknown option for logs: $arg" ;;
esac
done
[ -n "$files" ] || files="gateway-a.log gateway-b.log"
[ -d "$LOG_DIR" ] || die "No logs yet in $LOG_DIR."
cd "$LOG_DIR"
if $follow; then
# shellcheck disable=SC2086
exec tail -n 100 -F $files
fi
for arg in $files; do
if [ -f "$arg" ]; then
echo "==> $LOG_DIR/$arg <=="
tail -n 100 "$arg"
fi
done
}
cmd_check() {
local arg nginx_url=""
for arg in "$@"; do
case "$arg" in
--with-llm) ;;
*) die "Unknown option for check: $arg" ;;
esac
done
if ! gateway_pid a >/dev/null || ! gateway_pid b >/dev/null; then
die "Both Gateways must be running; run 'up' (or 'start a|b')."
fi
if nginx_pid >/dev/null; then
nginx_url="http://127.0.0.1:$NGINX_PORT"
fi
run_helper check --gateway "A=http://127.0.0.1:$GATEWAY_A_PORT" --gateway "B=http://127.0.0.1:$GATEWAY_B_PORT" \
${nginx_url:+--nginx "$nginx_url"} --state-dir "$STATE_DIR" "$@"
}
cmd_stop() {
local name="${1:-}" signal=TERM
[ -n "$name" ] || die "Usage: stop <a|b> [--kill]"
gateway_port "$name" >/dev/null
shift
case "${1:-}" in
"") ;;
--kill) signal=KILL ;;
*) die "Unknown option for stop: $1" ;;
esac
gateway_pid "$name" >/dev/null || die "Gateway $(upper "$name") is not running."
stop_process "gateway-$name" "app.gateway.app:app" "$signal"
}
cmd_start() {
local name="${1:-}"
[ -n "$name" ] && [ $# -eq 1 ] || die "Usage: start <a|b>"
gateway_port "$name" >/dev/null
start_gateway "$name"
}
cmd_restart() {
local name="${1:-}"
[ -n "$name" ] && [ $# -eq 1 ] || die "Usage: restart <a|b>"
gateway_port "$name" >/dev/null
stop_process "gateway-$name" "app.gateway.app:app" TERM
start_gateway "$name"
}
main() {
local command="${1:-help}"
[ $# -eq 0 ] || shift
case "$command" in
up) cmd_up "$@" ;;
down) cmd_down "$@" ;;
status) cmd_status ;;
logs) cmd_logs "$@" ;;
check) cmd_check "$@" ;;
stop) cmd_stop "$@" ;;
start) cmd_start "$@" ;;
restart) cmd_restart "$@" ;;
help | -h | --help) usage ;;
*)
echo "Unknown command: $command" >&2
usage >&2
exit 2
;;
esac
}
main "$@"