Gateway Drain Trigger

Port-less, restart-safe external drain signal for hosted / containerised gateway deployments

python
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.

Diagram

Port-less Drain Trigger

👤 Operator

📄 .drain_request.json
epoch-stamped marker

👁️ Marker watcher

🛡️ DrainMarkerPolicy

🛑 gateway.stop drain_timeout

✅ Clean exit

Quick Start

  1. Import the policy and epoch function
    python
    from praisonaiagents.gateway import DrainMarkerPolicy, current_epoch
  2. Build the policy once at startup
    python
    policy = DrainMarkerPolicy()
    epoch = current_epoch()
  3. 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)
  4. 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 a lifecycle: block.

    yaml
    # gateway.yaml
    lifecycle:
      drain:
        marker_path: "/data/gateway.drain"
    bash
    praisonai gateway start --config gateway.yaml

    The gateway polls the marker every 5 seconds and, on an epoch match, drains active sessions bounded by the gateway’s drain_timeout.

  5. Primary runtime — enable via CLI flag (no YAML)
    bash
    praisonai gateway start --drain-marker /data/gateway.drain

    The flag synthesizes the lifecycle.drain block, so CLI overrides win over gateway.yaml.

  6. 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.

Diagram
DrainMarkerPolicyGateway v2(epoch=E2 after reboot)Durable volume.drain_request.jsonGateway v1(epoch=E1)DrainMarkerPolicyGateway v2(epoch=E2 after reboot)Durable volume.drain_request.jsonGateway v1(epoch=E1)drains, exitsmarker survives rebootstarts cleanly, no wedgewrite {epoch: E1, action: drain}read marker on startupdrain_requested(marker, E2)False (foreign epoch — ignored)

Decision Tree — When Is a Marker Honoured?

Diagram

no / None

yes, dict

missing & require_epoch=True

missing & require_epoch=False

foreign

matches

yes

no

no

yes

marker present?

Ignore

epoch matches
current_epoch()?

Ignore — fail-safe

Honour

Ignore — stale

last_handled_epoch
same as marker?

Ignore — already handled

action in drain or empty
and string-typed?

Ignore — malformed

Honour drain


The Drain Marker Contract

Write this JSON to the marker path:

json
{
  "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
  • action defaults to "drain" if absent. Any other value (including a non-string) is ignored.
  • Missing / empty / non-string epoch is ignored unless require_epoch=False is passed to the policy.

Configuration Options

DrainMarkerPolicy constructor

python
from praisonaiagents.gateway import DrainMarkerPolicy

policy = DrainMarkerPolicy(require_epoch=True)
ParamTypeDefaultDescription
require_epochboolTrueWhen 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

ParamTypeDefaultDescription
marker`dict \None`—Parsed marker contents, or None when no marker file is present. Non-dicts return False.
current_epochstr—The current instantiation epoch — pass the return value of current_epoch().
nowfloat—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

SignalSourceNotes
Kernel boot id/proc/sys/kernel/random/boot_idChanges on every reboot.
PID-1 start timeField 22 of /proc/1/statChanges on every boot / container (re)start.
Fail-closed default"" (empty string) when either signal is unavailableMac / Windows / sandboxed containers without /proc return "". Every marker is foreign to an empty epoch unless require_epoch=False.
python
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:.

yaml
lifecycle:
  drain:
    marker_path: "/data/gateway.drain"
FieldTypeDefaultDescription
marker_pathstrNonePath 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.

SurfaceField / FlagDescription
CLI--drain-marker <path>Overrides lifecycle.drain.marker_path.

Observability

When the built-in watcher runs, health() surfaces it under the lifecycle object:

json
{
  "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

python
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

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)

3. Accepting legacy markers without an epoch (advanced)

python
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