KeepAlive im Verbindungspool

Beide Pool-Clients können ihre Leerlaufverbindungen in einem festen Intervall mit dem Serverjob krn.CheckServerConnection anpingen. Der Job ist serverseitig nur ein Zähler, der über die bestehende Session läuft — das billigste verfügbare Lebenszeichen. Er hält NAT-, Firewall- und Idle-Timeouts offen und deckt still weggeräumte Sockets auf, weil die Schreib- oder Leseoperation dann fehlschlägt.

Ohne KeepAlive fällt eine tote Verbindung erst beim nächsten fachlichen Aufruf auf: dieser Aufruf schlägt fehl, der Pool wirft die Verbindung weg, und erst der Aufruf danach läuft wieder. Genau das verhindert das KeepAlive.

1. Konfiguration

from ecmind_blue_client.pool import SyncPoolClient

pool = SyncPoolClient(
    servers="server1:4000:1",
    username="<user>",
    password="<pass>",
    keepalive_interval=300,   # Sekunden
)

None (Default)

KeepAlive deaktiviert, es wird kein Hintergrund-Worker gestartet

⇐ 0

ebenfalls deaktiviert

> 0

Sekunden zwischen zwei Durchläufen

AsyncPoolClient nimmt denselben Parameter entgegen und verwendet statt eines Threads einen asyncio.Task.

Das Intervall richtet sich nach dem kürzesten Idle-Timeout auf dem Weg zum Server (Firewall, Loadbalancer, NAT-Gateway) und sollte deutlich darunter liegen. 300 Sekunden sind ein brauchbarer Startwert.

2. Ablauf eines Durchlaufs

  1. Der Worker startet verzögert: erst wenn die erste Verbindung erzeugt wird. Ein Pool, der nie benutzt wird, erzeugt keinen Thread und keinen Task.

  2. Das Intervall wird zwischen dem Ende eines Durchlaufs und dem Beginn des nächsten gemessen. Ein langsamer Durchlauf führt daher weder zu Aufholbursts noch zu überlappenden Durchläufen.

  3. Pro Durchlauf wird die Anzahl der aktuell freien Verbindungen als Momentaufnahme genommen; genau so viele Verbindungen werden geprüft. Eine geprüfte und zurückgelegte Verbindung kann im selben Durchlauf nicht erneut geprüft werden.

  4. Jede entnommene Verbindung wird mit krn.CheckServerConnection (Flags=0) geprüft:

    Erfolg

    Verbindung geht zurück in den Pool, keepalive_count und last_keepalive_at werden hochgezählt

    Fehler (Returncode != 0 oder Netzwerkfehler)

    Verbindung wird geschlossen und aus der Buchführung entfernt

3. Eigenschaften

Nur Leerlaufverbindungen

Eine gerade ausgeliehene Verbindung ist aus der Warteschlange heraus und für den Durchlauf unsichtbar. Laufende Jobs werden nie gestört.

Keine Sperre über den RPC-Aufruf

Nur die Warteschlangen-Buchführung läuft unter dem Pool-Lock, der Serverjob selbst nicht. Ein hängender Server blockiert den Pool also nicht für die Dauer des Durchlaufs.

Kein Neuaufbau im Durchlauf

Eine kaputte Verbindung wird nur geschlossen, nicht ersetzt. Der Pool schrumpft, statt kaputte Slots zu blockieren; der nächste Aufruf erzeugt bei Bedarf eine frische Verbindung.

Kein Fehler nach aussen

Ausfälle landen ausschliesslich auf logging.DEBUG im Logger ecmind_blue_client.pool._sync_pool_client bzw. …​_async_pool_client. Es gibt kein Event, keinen Callback und keine Exception im aufrufenden Thread.

Getrennt von der Anwendungsstatistik

KeepAlive-Verkehr zählt in keepalive_count / last_keepalive_at, nicht in call_count / last_call_at.

4. Was das KeepAlive nicht tut

  • Es prüft nicht die konfigurierten, aber gerade nicht verbundenen Server. Dafür gibt es ecm.check_connections(), das pro Server eine eigene Wegwerf-Verbindung aufbaut.

  • Es rührt die geteilte Server-Session nicht an. Ist die serverseitige Session weg (Serverneustart, Session-Timeout, krn.SessionDrop), erkennt das erst der nächste Verbindungsaufbau: krn.SessionAttach liefert dann ein abweichendes SessionGUID, der veraltete Eintrag wird verworfen und ein vollständiger Login durchgeführt. KeepAlive und Session-Recovery sind zwei unabhängige Mechanismen, die sich ergänzen: das KeepAlive entfernt die tote Verbindung, der nächste execute() baut eine neue mit frischer Session auf.

5. Statistik auslesen

for stats in pool.connection_stats():
    print(
        f"{stats.hostname}:{stats.port} "
        f"calls={stats.call_count} "
        f"keepalives={stats.keepalive_count} "
        f"last_keepalive={stats.last_keepalive_at}"
    )

6. Pool sauber beenden

Solange ein KeepAlive läuft, sollte der Pool explizit geschlossen werden. close() (sync) bzw. aclose() (async) stoppt den Worker und schliesst alle Leerlaufverbindungen; beide sind mehrfach aufrufbar.

# Sync
pool = SyncPoolClient(..., keepalive_interval=300)
try:
    ...
finally:
    pool.close()

# oder als Kontextmanager
with SyncPoolClient(..., keepalive_interval=300) as pool:
    ...
# Async
async with AsyncPoolClient(..., keepalive_interval=300) as pool:
    ...

Der Worker hält den Pool nur über eine schwache Referenz. Ein Pool, der ohne close() einfach losgelassen wird, kann daher weiterhin vom Garbage Collector eingesammelt werden — der Worker beendet sich dann von selbst. Das ist die Rückfallebene, nicht der empfohlene Weg: Zeitpunkt und Reihenfolge bestimmt dann der Garbage Collector.

7. FastAPI

Im Lifespan gehört der Pool einmal aufgebaut und beim Herunterfahren explizit geschlossen:

@asynccontextmanager
async def lifespan(app: FastAPI):
    client = AsyncPoolClient(
        servers=os.environ["ECM_SERVERS"],
        username=os.environ["ECM_USERNAME"],
        password=os.environ["ECM_PASSWORD"],
        keepalive_interval=300,
    )
    app.state.ecm = ECM(client)
    yield
    await client.aclose()
    app.state.ecm = None

Wird nur die ECM-Instanz herumgereicht, kommt man über ecm.client an den Pool: await ecm.client.aclose().

Zwei Punkte, die in einem Web-Service leicht übersehen werden:

  • Der asyncio.Task wird beim Erzeugen der ersten Verbindung im laufenden Event Loop angelegt, nicht im Konstruktor. Ein im Lifespan gebauter Pool bindet sich damit immer an den Loop, der ihn auch benutzt.

  • Läuft der Service mit mehreren Worker-Prozessen (uvicorn --workers, Gunicorn), hat jeder Prozess seinen eigenen Pool und damit seinen eigenen KeepAlive-Worker. Das Intervall gilt pro Prozess.

Das vollständige Beispiel steht in FastAPI-Integration.