feat: release v1.0.0

This commit is contained in:
chrishuan
2026-06-09 15:28:05 +08:00
parent bc1575a0da
commit 9b7dbdd7b2
83 changed files with 7737 additions and 335 deletions
@@ -971,11 +971,19 @@ class MemoryTencentdbProvider(MemoryProvider):
except Exception as e:
logger.debug("memory-tencentdb session end failed: %s", e)
# Note: do NOT shut down the supervisor/Gateway here — it may serve
# other sessions. The Gateway manages its own lifecycle.
# We *do* drop our reference to the supervisor so any in-flight
# _try_recover_gateway() call sees self._supervisor is None and
# bails out instead of resurrecting a released provider.
# Stop only the Gateway process this supervisor spawned. If the
# provider merely attached to an already-running external Gateway,
# GatewaySupervisor.shutdown() is a no-op because _process is None.
supervisor = self._supervisor
if supervisor is not None:
try:
supervisor.shutdown()
except Exception as e:
logger.debug("memory-tencentdb supervisor shutdown failed: %s", e)
# Drop our reference so any in-flight _try_recover_gateway() call sees
# self._supervisor is None and bails out instead of resurrecting a
# released provider.
self._client = None
self._gateway_available = False
self._initialized = False
@@ -8,12 +8,22 @@ On shutdown(), sends a flush signal and waits for clean exit.
from __future__ import annotations
import contextlib
import logging
import os
import re
import shlex
import signal
import subprocess
import tempfile
import threading
import time
from typing import IO, Optional
from typing import Dict, IO, Iterator, Optional
try:
import fcntl
except ImportError: # pragma: no cover - Windows fallback
fcntl = None # type: ignore[assignment]
from .client import MemoryTencentdbSdkClient
@@ -31,6 +41,39 @@ HEALTH_CHECK_RETRIES = 3 # retries for is_running check
# Log file rotation parameters
LOG_TAIL_BYTES_ON_CRASH = 2048 # bytes of stderr log to surface on startup crash
# Startup single-flight state. The thread lock prevents multiple supervisor
# instances in the same Python process from spawning the same Gateway
# concurrently; the optional fcntl lock extends the guard across processes.
_START_LOCKS: Dict[str, threading.Lock] = {}
_START_LOCKS_GUARD = threading.Lock()
def _lock_key(host: str, port: int) -> str:
safe_host = re.sub(r"[^A-Za-z0-9_.-]", "_", host)
return f"{safe_host}-{port}"
@contextlib.contextmanager
def _startup_singleflight(host: str, port: int) -> Iterator[None]:
key = _lock_key(host, port)
with _START_LOCKS_GUARD:
thread_lock = _START_LOCKS.setdefault(key, threading.Lock())
lock_path = os.path.join(tempfile.gettempdir(), f"memory-tencentdb-gateway-{key}.lock")
with thread_lock:
lock_file = None
try:
if fcntl is not None:
lock_file = open(lock_path, "a", encoding="utf-8")
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
yield
finally:
if lock_file is not None:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
finally:
lock_file.close()
class GatewaySupervisor:
"""Manages the memory-tencentdb Gateway sidecar lifecycle."""
@@ -76,6 +119,7 @@ class GatewaySupervisor:
self._stdout_log: Optional[IO[bytes]] = None
self._stderr_log: Optional[IO[bytes]] = None
self._stderr_log_path: Optional[str] = None
self._shutdown_requested = False
# Resolve Gateway command
# Priority: explicit arg > MEMORY_TENCENTDB_GATEWAY_CMD env
@@ -146,77 +190,96 @@ class GatewaySupervisor:
logger.info("memory-tencentdb Gateway already running at %s", self._base_url)
return True
# If we previously spawned a child and it has since died, drop the
# stale Popen handle so the new spawn below isn't shadowed by a
# zombie reference. Without this, a crashed-then-respawned Gateway
# would keep ``self._process`` pointing at the dead PID forever and
# ``is_process_alive()`` would mislead the watchdog.
self._reap_dead_process()
# Try to start the Gateway
if not self._gateway_cmd:
logger.warning(
"memory-tencentdb Gateway is not running and no gateway command configured. "
"Set MEMORY_TENCENTDB_GATEWAY_CMD environment variable or pass gateway_cmd to supervisor. "
"memory-tencentdb memory will be unavailable."
)
return False
logger.info("Starting memory-tencentdb Gateway: %s", self._gateway_cmd)
try:
env = os.environ.copy()
env["MEMORY_TENCENTDB_GATEWAY_PORT"] = str(self._port)
env["MEMORY_TENCENTDB_GATEWAY_HOST"] = self._host
# Note: we deliberately do NOT inject TDAI_GATEWAY_API_KEY into
# the child's env from here. Whether the Gateway enforces auth is
# the operator's call — they configure it on the Gateway side
# (env, yaml, docker run, systemd unit) just like any other
# Gateway setting. The supervisor's ``api_key`` is purely the
# client-side Bearer token used for outbound requests.
# Redirect child stdout/stderr to log files instead of PIPE.
# Using PIPE without an active reader will deadlock the child once
# the pipe buffer (~64 KB) fills up. A log directory next to the
# data dir keeps logs inspectable on crash while eliminating the
# blocking risk entirely.
log_dir = self._resolve_log_dir()
try:
os.makedirs(log_dir, exist_ok=True)
except OSError as e:
logger.warning(
"memory-tencentdb Gateway: failed to create log dir %s (%s); "
"falling back to DEVNULL", log_dir, e,
with _startup_singleflight(self._host, self._port):
# Another supervisor may have started the Gateway while we were
# waiting on the single-flight lock. Re-probe before spawning.
if self.is_running():
logger.info(
"memory-tencentdb Gateway became available at %s while waiting for startup lock",
self._base_url,
)
log_dir = None
return True
if log_dir is not None:
stdout_path = os.path.join(log_dir, "gateway.stdout.log")
stderr_path = os.path.join(log_dir, "gateway.stderr.log")
# Append mode: preserve previous runs for postmortem.
self._stdout_log = open(stdout_path, "ab", buffering=0)
self._stderr_log = open(stderr_path, "ab", buffering=0)
self._stderr_log_path = stderr_path
stdout_target: object = self._stdout_log
stderr_target: object = self._stderr_log
else:
stdout_target = subprocess.DEVNULL
stderr_target = subprocess.DEVNULL
# If we previously spawned a child and it has since died, drop the
# stale Popen handle so the new spawn below isn't shadowed by a
# zombie reference. Without this, a crashed-then-respawned Gateway
# would keep ``self._process`` pointing at the dead PID forever and
# ``is_process_alive()`` would mislead the watchdog.
self._reap_dead_process()
self._process = subprocess.Popen(
shlex.split(self._gateway_cmd),
env=env,
stdout=stdout_target,
stderr=stderr_target,
start_new_session=True, # Detach from parent process group
)
except Exception as e:
logger.error("Failed to start memory-tencentdb Gateway: %s", e)
self._close_log_handles()
return False
# Try to start the Gateway
if not self._gateway_cmd:
logger.warning(
"memory-tencentdb Gateway is not running and no gateway command configured. "
"Set MEMORY_TENCENTDB_GATEWAY_CMD environment variable or pass gateway_cmd to supervisor. "
"memory-tencentdb memory will be unavailable."
)
return False
# Wait for health check
return self._wait_for_health()
logger.info("Starting memory-tencentdb Gateway: %s", self._gateway_cmd)
self._shutdown_requested = False
try:
env = os.environ.copy()
# The Python provider historically used MEMORY_TENCENTDB_* while
# src/gateway/config.ts reads TDAI_GATEWAY_*. Export both so a
# non-default supervisor port cannot accidentally spawn a child
# that still binds the default 8420.
env["MEMORY_TENCENTDB_GATEWAY_PORT"] = str(self._port)
env["MEMORY_TENCENTDB_GATEWAY_HOST"] = self._host
env["TDAI_GATEWAY_PORT"] = str(self._port)
env["TDAI_GATEWAY_HOST"] = self._host
# Note: we deliberately do NOT inject TDAI_GATEWAY_API_KEY into
# the child's env from here. Whether the Gateway enforces auth is
# the operator's call — they configure it on the Gateway side
# (env, yaml, docker run, systemd unit) just like any other
# Gateway setting. The supervisor's ``api_key`` is purely the
# client-side Bearer token used for outbound requests.
# Redirect child stdout/stderr to log files instead of PIPE.
# Using PIPE without an active reader will deadlock the child once
# the pipe buffer (~64 KB) fills up. A log directory next to the
# data dir keeps logs inspectable on crash while eliminating the
# blocking risk entirely.
log_dir = self._resolve_log_dir()
try:
os.makedirs(log_dir, exist_ok=True)
except OSError as e:
logger.warning(
"memory-tencentdb Gateway: failed to create log dir %s (%s); "
"falling back to DEVNULL", log_dir, e,
)
log_dir = None
if log_dir is not None:
stdout_path = os.path.join(log_dir, "gateway.stdout.log")
stderr_path = os.path.join(log_dir, "gateway.stderr.log")
# Append mode: preserve previous runs for postmortem.
self._stdout_log = open(stdout_path, "ab", buffering=0)
self._stderr_log = open(stderr_path, "ab", buffering=0)
self._stderr_log_path = stderr_path
stdout_target: object = self._stdout_log
stderr_target: object = self._stderr_log
else:
stdout_target = subprocess.DEVNULL
stderr_target = subprocess.DEVNULL
self._process = subprocess.Popen(
shlex.split(self._gateway_cmd),
env=env,
stdout=stdout_target,
stderr=stderr_target,
start_new_session=True,
)
except Exception as e:
logger.error("Failed to start memory-tencentdb Gateway: %s", e)
self._close_log_handles()
return False
# Keep the lock until the spawned process becomes healthy; otherwise
# a second waiter can observe the port as down during cold start and
# launch a duplicate process that immediately hits EADDRINUSE.
return self._wait_for_health()
def _resolve_log_dir(self) -> str:
"""Pick a directory to store Gateway stdout/stderr logs.
@@ -269,6 +332,10 @@ class GatewaySupervisor:
"""Wait for the Gateway to become healthy."""
start = time.monotonic()
while time.monotonic() - start < HEALTH_CHECK_MAX_WAIT:
if self._shutdown_requested:
logger.info("memory-tencentdb Gateway startup wait cancelled by shutdown")
return False
# Check if process died
if self._process and self._process.poll() is not None:
rc = self._process.returncode
@@ -303,20 +370,32 @@ class GatewaySupervisor:
def shutdown(self) -> None:
"""Shut down the managed Gateway process (if we started it)."""
self._shutdown_requested = True
if self._process is None:
return
logger.info("Shutting down memory-tencentdb Gateway...")
try:
# Send SIGTERM for graceful shutdown
self._process.terminate()
proc = self._process
if proc.poll() is None:
# The Gateway is started with start_new_session=True. Terminate
# the whole process group so `pnpm -> tsx -> node server.ts`
# does not leave the real listener orphaned after the top-level
# wrapper exits.
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except Exception:
proc.terminate()
try:
self._process.wait(timeout=10)
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
logger.warning("memory-tencentdb Gateway did not exit in 10s, sending SIGKILL")
self._process.kill()
self._process.wait(timeout=5)
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
proc.kill()
proc.wait(timeout=5)
except Exception as e:
logger.warning("Error shutting down memory-tencentdb Gateway: %s", e)
finally:
@@ -231,6 +231,69 @@ def test_reap_dead_process_keeps_alive_handle():
assert sup._process is alive
def test_ensure_running_singleflight_prevents_duplicate_spawn(monkeypatch):
"""Concurrent supervisors for one port must spawn at most one Gateway."""
port = 28421
running = False
spawn_envs = []
spawn_lock = threading.Lock()
class FakePopen:
pid = 12345
returncode = None
def poll(self):
return None
def fake_is_running(self):
return running
def fake_wait_for_health(self):
nonlocal running
time.sleep(0.1)
running = True
return True
def fake_popen(_argv, *, env, stdout, stderr, start_new_session):
with spawn_lock:
spawn_envs.append(dict(env))
assert start_new_session is True
return FakePopen()
monkeypatch.setattr(supervisor_module.GatewaySupervisor, "is_running", fake_is_running)
monkeypatch.setattr(supervisor_module.GatewaySupervisor, "_wait_for_health", fake_wait_for_health)
monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen)
supervisors = [
supervisor_module.GatewaySupervisor(
host="127.0.0.1",
port=port,
gateway_cmd="fake gateway",
)
for _ in range(6)
]
barrier = threading.Barrier(len(supervisors))
results = []
def worker(sup):
barrier.wait()
results.append(sup.ensure_running())
threads = [threading.Thread(target=worker, args=(sup,)) for sup in supervisors]
for t in threads:
t.start()
for t in threads:
t.join(timeout=3)
assert results == [True] * len(supervisors)
assert len(spawn_envs) == 1
env = spawn_envs[0]
assert env["MEMORY_TENCENTDB_GATEWAY_PORT"] == str(port)
assert env["MEMORY_TENCENTDB_GATEWAY_HOST"] == "127.0.0.1"
assert env["TDAI_GATEWAY_PORT"] == str(port)
assert env["TDAI_GATEWAY_HOST"] == "127.0.0.1"
# ---------------------------------------------------------------------------
# Watchdog: detects death, resurrects, and reattaches
# ---------------------------------------------------------------------------