Gateway Drain Trigger
Port-less, restart-safe external drain signal for hosted / containerised gateway deployments
from praisonaiagents import Agent
agent = Agent(name="drain-agent", instructions="Drain the gateway queue gracefully.")
agent.start("Drain all pending messages before shutting down the gateway.")Ask a running gateway to finish in-flight turns and exit — without exposing any inbound port, and without a left-over signal wedging a restarted instance.
The user drops a drain marker file; the gateway finishes in-flight turns and exits without exposing an inbound drain port.
Quick Start
- Import the policy and epoch function
python from praisonaiagents.gateway import DrainMarkerPolicy, current_epoch - Build the policy once at startup
python policy = DrainMarkerPolicy() epoch = current_epoch() - Run a watcher loop
python import asyncio import json import os from time import monotonic from praisonaiagents.gateway import DrainMarkerPolicy, current_epoch policy = DrainMarkerPolicy() last_handled = None path = os.path.expanduser("~/.praisonai/gateway/.drain_request.json") async def watch(gateway): global last_handled while True: if os.path.exists(path): with open(path) as f: marker = json.load(f) else: marker = None if policy.drain_requested( marker, current_epoch(), monotonic(), last_handled_epoch=last_handled, ): last_handled = marker.get("epoch") await gateway.stop(drain_timeout=30) return await asyncio.sleep(1) - Primary runtime — enable the built-in watcher via gateway.yaml
The primary
WebSocketGateway(praisonai gateway start) ships a built-in marker watcher. Point it at a marker path with alifecycle:block.yaml # gateway.yaml lifecycle: drain: marker_path: "/data/gateway.drain"bash praisonai gateway start --config gateway.yamlThe gateway polls the marker every 5 seconds and, on an epoch match, drains active sessions bounded by the gateway’s
drain_timeout. - Primary runtime — enable via CLI flag (no YAML)
bash praisonai gateway start --drain-marker /data/gateway.drainThe flag synthesizes the
lifecycle.drainblock, so CLI overrides win overgateway.yaml. - Trigger a drain from a deploy step
python import json, os from praisonaiagents.gateway import current_epoch marker = {"epoch": current_epoch(), "action": "drain"} tmp = "/data/gateway.drain.tmp" with open(tmp, "w") as f: json.dump(marker, f) os.replace(tmp, "/data/gateway.drain") # atomic write
How It Works
Restart-Safety Story
The entire point of this feature: a marker stamped with current_epoch() is silently ignored by any other instance of the gateway — even if the marker file survives a reboot on a durable volume.
Decision Tree — When Is a Marker Honoured?
The Drain Marker Contract
Write this JSON to the marker path:
{
"epoch": "<output of current_epoch() at write time>",
"action": "drain"
}- Default marker path (convention from PraisonAI #2390, not yet enforced in code):
~/.praisonai/gateway/.drain_request.json actiondefaults to"drain"if absent. Any other value (including a non-string) is ignored.- Missing / empty / non-string
epochis ignored unlessrequire_epoch=Falseis passed to the policy.
Configuration Options
DrainMarkerPolicy constructor
from praisonaiagents.gateway import DrainMarkerPolicy
policy = DrainMarkerPolicy(require_epoch=True)| Param | Type | Default | Description |
|---|---|---|---|
require_epoch | bool | True | When True, a marker without a non-empty string epoch is ignored (fail-safe). Set False only when intentionally accepting hand-rolled or legacy markers. |
DrainMarkerPolicy.drain_requested() parameters
| Param | Type | Default | Description | |
|---|---|---|---|---|
marker | `dict \ | None` | — | Parsed marker contents, or None when no marker file is present. Non-dicts return False. |
current_epoch | str | — | The current instantiation epoch — pass the return value of current_epoch(). | |
now | float | — | A monotonic timestamp. Unused by the default policy, but keeps the call site stable when subclasses add TTL/debounce. | |
last_handled_epoch | `str \ | None` | None (kwarg-only) | If equal to the marker’s epoch, the request is treated as already handled and ignored — a polling watcher fires only once per instantiation. |
current_epoch() — what it returns
| Signal | Source | Notes |
|---|---|---|
| Kernel boot id | /proc/sys/kernel/random/boot_id | Changes on every reboot. |
| PID-1 start time | Field 22 of /proc/1/stat | Changes on every boot / container (re)start. |
| Fail-closed default | "" (empty string) when either signal is unavailable | Mac / Windows / sandboxed containers without /proc return "". Every marker is foreign to an empty epoch unless require_epoch=False. |
from praisonaiagents.gateway import current_epoch
epoch = current_epoch()
# Linux: "550e8400-e29b-41d4-a716-446655440000:12345"
# Mac / Windows / no /proc: ""lifecycle.drain: YAML block (WebSocketGateway)
The primary WebSocketGateway reads the built-in watcher from a lifecycle: block at the top level of gateway.yaml or nested under gateway:.
lifecycle:
drain:
marker_path: "/data/gateway.drain"| Field | Type | Default | Description |
|---|---|---|---|
marker_path | str | None | Path to the epoch-stamped marker file to watch. Setting it enables the built-in watcher. |
The watcher polls every 5 seconds. On an epoch match it sets _draining = True and calls _drain_active_sessions(reason="drain-marker", timeout=drain_timeout), so the gateway’s existing drain_timeout bounds the marker-triggered drain.
| Surface | Field / Flag | Description |
|---|---|---|
| CLI | --drain-marker <path> | Overrides lifecycle.drain.marker_path. |
Observability
When the built-in watcher runs, health() surfaces it under the lifecycle object:
{
"status": "healthy",
"lifecycle": {
"scale_to_zero": false,
"dormant": false,
"drain_marker_watch": true
}
}drain_marker_watch is true while the watch loop is running. The lifecycle field appears only when a lifecycle feature is configured.
Common Patterns
1. Operator-side: write the marker
import json
import os
from praisonaiagents.gateway import current_epoch
path = os.path.expanduser("~/.praisonai/gateway/.drain_request.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path + ".tmp", "w") as f:
json.dump({"epoch": current_epoch(), "action": "drain"}, f)
os.replace(path + ".tmp", path)Writing to <path>.tmp then os.replace() makes the write atomic — a half-written JSON file is treated as malformed and ignored.
2. Gateway-side: minimal watcher loop
import asyncio
import json
import os
from time import monotonic
from praisonaiagents.gateway import DrainMarkerPolicy, current_epoch
policy = DrainMarkerPolicy()
last_handled = None
path = os.path.expanduser("~/.praisonai/gateway/.drain_request.json")
async def watch(gateway):
global last_handled
while True:
if os.path.exists(path):
with open(path) as f:
marker = json.load(f)
else:
marker = None
if policy.drain_requested(
marker,
current_epoch(),
monotonic(),
last_handled_epoch=last_handled,
):
last_handled = marker.get("epoch")
await gateway.stop(drain_timeout=30)
return
await asyncio.sleep(1)3. Accepting legacy markers without an epoch (advanced)
from praisonaiagents.gateway import DrainMarkerPolicy
policy = DrainMarkerPolicy(require_epoch=False)Only do this if you own both ends and have a different staleness story.
Best Practices
Always stamp markers with current_epoch()
The whole restart-safety guarantee depends on it. Leave require_epoch=True (the default). An unstamped marker is silently ignored — this is intentional and protects newly started instances from stale files.
Write the marker atomically
Write to <path>.tmp then os.replace() — a half-written JSON file is parsed as malformed and ignored, leaving the previous request still active. Atomic rename is the only safe pattern on most filesystems.
Pair with drain_timeout
DrainMarkerPolicy only decides when to drain; the actual bounded wait still goes through gateway.stop(drain_timeout=...) documented on the Session Continuity page. Without a drain_timeout, shutdown cancels in-flight turns immediately.
Don't store secrets in the marker
The epoch is non-secret and opaque. Treat the marker file as world-readable convention metadata, not a control-plane secret. Anyone who can write to the marker path can trigger a drain.
Related
ScaleToZeroPolicy — sibling idle-policy predicate
Session Continuitydrain_timeout and in-process drain
Crash-Loop GuardSibling lifecycle policy — stop auto-resuming a crash-looping channel
Idle CompactionSibling lifecycle policy — background compaction for idle sessions.
GatewayGateway and Control Plane top-level page