superlocalmemory 3.4.25 → 3.4.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +8 -1
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/daemon.py +90 -16
- package/src/superlocalmemory/cli/doctor_cmd.py +152 -0
- package/src/superlocalmemory/cli/main.py +28 -0
- package/src/superlocalmemory/cli/post_install.py +15 -0
- package/src/superlocalmemory/cli/setup_wizard.py +20 -0
- package/src/superlocalmemory/cli/version_banner.py +183 -0
- package/src/superlocalmemory/cli/wizard_v3426_options.py +129 -0
- package/src/superlocalmemory/core/clock_monitor.py +45 -0
- package/src/superlocalmemory/core/db_pool.py +80 -0
- package/src/superlocalmemory/core/engine.py +75 -30
- package/src/superlocalmemory/core/engine_capabilities.py +24 -0
- package/src/superlocalmemory/core/engine_lock.py +75 -0
- package/src/superlocalmemory/core/error_catalog.py +113 -0
- package/src/superlocalmemory/core/error_envelope.py +60 -0
- package/src/superlocalmemory/core/file_lock.py +92 -0
- package/src/superlocalmemory/core/loop_watchdog.py +56 -0
- package/src/superlocalmemory/core/priority_queue.py +61 -0
- package/src/superlocalmemory/core/queue_dispatcher.py +73 -0
- package/src/superlocalmemory/core/rate_limit.py +151 -0
- package/src/superlocalmemory/core/recall_queue.py +370 -0
- package/src/superlocalmemory/core/recall_worker.py +10 -0
- package/src/superlocalmemory/core/safe_fs.py +108 -0
- package/src/superlocalmemory/hooks/auto_capture.py +34 -12
- package/src/superlocalmemory/hooks/auto_recall.py +36 -9
- package/src/superlocalmemory/mcp/_daemon_proxy.py +107 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +121 -0
- package/src/superlocalmemory/mcp/resources.py +8 -5
- package/src/superlocalmemory/mcp/server.py +38 -9
- package/src/superlocalmemory/mcp/tools_active.py +21 -9
- package/src/superlocalmemory/mcp/tools_core.py +13 -9
- package/src/superlocalmemory/mcp/tools_evolution.py +4 -2
- package/src/superlocalmemory/mcp/tools_learning.py +5 -3
- package/src/superlocalmemory/mcp/tools_mesh.py +5 -3
- package/src/superlocalmemory/mcp/tools_v3.py +18 -22
- package/src/superlocalmemory/mcp/tools_v33.py +65 -2
- package/src/superlocalmemory/migrations/__init__.py +5 -0
- package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +144 -0
- package/src/superlocalmemory/server/unified_daemon.py +128 -12
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""MemoryEngine capability levels.
|
|
6
|
+
|
|
7
|
+
LIGHT: SQLite + profile only. Suitable for clients that must stay small in
|
|
8
|
+
memory (e.g. multi-IDE MCP processes) and route heavy work elsewhere.
|
|
9
|
+
|
|
10
|
+
FULL: DB layer + embedder + retrieval engine + LLM. Matches the historical
|
|
11
|
+
v3.4.25 engine surface exactly and remains the default for backward compat.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Capabilities(enum.Enum):
|
|
19
|
+
LIGHT = "light"
|
|
20
|
+
FULL = "full"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CapabilityError(RuntimeError):
|
|
24
|
+
"""Raised when a LIGHT-mode engine is asked for a FULL-mode operation."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Reader-writer lock used by the shared worker engine.
|
|
6
|
+
|
|
7
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EngineRWLock:
|
|
18
|
+
"""Writer-priority reader-writer lock.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
lock = EngineRWLock()
|
|
22
|
+
with lock.read():
|
|
23
|
+
...
|
|
24
|
+
with lock.write():
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
Not reentrant.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_cond", "_readers", "_writers_waiting", "_writer_active")
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._cond = threading.Condition()
|
|
34
|
+
self._readers: int = 0
|
|
35
|
+
self._writers_waiting: int = 0
|
|
36
|
+
self._writer_active: bool = False
|
|
37
|
+
|
|
38
|
+
@contextmanager
|
|
39
|
+
def read(self) -> Iterator[None]:
|
|
40
|
+
with self._cond:
|
|
41
|
+
while self._writer_active or self._writers_waiting > 0:
|
|
42
|
+
self._cond.wait()
|
|
43
|
+
self._readers += 1
|
|
44
|
+
try:
|
|
45
|
+
yield
|
|
46
|
+
finally:
|
|
47
|
+
with self._cond:
|
|
48
|
+
self._readers -= 1
|
|
49
|
+
if self._readers == 0:
|
|
50
|
+
self._cond.notify_all()
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def write(self) -> Iterator[None]:
|
|
54
|
+
with self._cond:
|
|
55
|
+
self._writers_waiting += 1
|
|
56
|
+
try:
|
|
57
|
+
while self._readers > 0 or self._writer_active:
|
|
58
|
+
self._cond.wait()
|
|
59
|
+
self._writer_active = True
|
|
60
|
+
finally:
|
|
61
|
+
self._writers_waiting -= 1
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
with self._cond:
|
|
66
|
+
self._writer_active = False
|
|
67
|
+
self._cond.notify_all()
|
|
68
|
+
|
|
69
|
+
def stats(self) -> dict[str, int | bool]:
|
|
70
|
+
with self._cond:
|
|
71
|
+
return {
|
|
72
|
+
"readers": self._readers,
|
|
73
|
+
"writers_waiting": self._writers_waiting,
|
|
74
|
+
"writer_active": self._writer_active,
|
|
75
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""User-facing strings for queue error codes."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
from superlocalmemory.core.error_envelope import ErrorCode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorEntry(TypedDict):
|
|
15
|
+
code: str
|
|
16
|
+
title: str
|
|
17
|
+
cli_message: str
|
|
18
|
+
recovery: list[str]
|
|
19
|
+
exit_code: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CATALOG: dict[str, ErrorEntry] = {
|
|
23
|
+
ErrorCode.RATE_LIMITED.value: {
|
|
24
|
+
"code": "RATE_LIMITED",
|
|
25
|
+
"title": "Rate limited",
|
|
26
|
+
"cli_message": "Too many recalls in the last second.",
|
|
27
|
+
"recovery": [
|
|
28
|
+
"Wait a moment and try again.",
|
|
29
|
+
"For batch work, export SLM_RATE_LIMIT_PER_AGENT=50 and restart the daemon.",
|
|
30
|
+
],
|
|
31
|
+
"exit_code": 3,
|
|
32
|
+
},
|
|
33
|
+
ErrorCode.QUEUE_FULL.value: {
|
|
34
|
+
"code": "QUEUE_FULL",
|
|
35
|
+
"title": "Queue is full",
|
|
36
|
+
"cli_message": "The request queue cannot accept more work right now.",
|
|
37
|
+
"recovery": [
|
|
38
|
+
"Retry with backoff.",
|
|
39
|
+
"Run: slm queue status",
|
|
40
|
+
],
|
|
41
|
+
"exit_code": 5,
|
|
42
|
+
},
|
|
43
|
+
ErrorCode.TIMEOUT.value: {
|
|
44
|
+
"code": "TIMEOUT",
|
|
45
|
+
"title": "Timed out",
|
|
46
|
+
"cli_message": "The recall did not finish in the allotted time.",
|
|
47
|
+
"recovery": [
|
|
48
|
+
"Retry.",
|
|
49
|
+
"Run: slm doctor",
|
|
50
|
+
],
|
|
51
|
+
"exit_code": 2,
|
|
52
|
+
},
|
|
53
|
+
ErrorCode.CANCELLED.value: {
|
|
54
|
+
"code": "CANCELLED",
|
|
55
|
+
"title": "Cancelled",
|
|
56
|
+
"cli_message": "The request was cancelled before it completed.",
|
|
57
|
+
"recovery": ["Re-issue the request if needed."],
|
|
58
|
+
"exit_code": 6,
|
|
59
|
+
},
|
|
60
|
+
ErrorCode.DEAD_LETTER.value: {
|
|
61
|
+
"code": "DEAD_LETTER",
|
|
62
|
+
"title": "Request failed after retries",
|
|
63
|
+
"cli_message": (
|
|
64
|
+
"The request could not complete after the maximum number of "
|
|
65
|
+
"attempts. Your query is preserved in the dead-letter queue."
|
|
66
|
+
),
|
|
67
|
+
"recovery": [
|
|
68
|
+
"Inspect: slm queue dlq",
|
|
69
|
+
"Run: slm doctor",
|
|
70
|
+
],
|
|
71
|
+
"exit_code": 4,
|
|
72
|
+
},
|
|
73
|
+
ErrorCode.DAEMON_DOWN.value: {
|
|
74
|
+
"code": "DAEMON_DOWN",
|
|
75
|
+
"title": "Daemon unreachable",
|
|
76
|
+
"cli_message": "Cannot reach the SLM daemon.",
|
|
77
|
+
"recovery": [
|
|
78
|
+
"Start the daemon: slm daemon start",
|
|
79
|
+
"Check status: slm daemon status",
|
|
80
|
+
],
|
|
81
|
+
"exit_code": 7,
|
|
82
|
+
},
|
|
83
|
+
ErrorCode.INTERNAL.value: {
|
|
84
|
+
"code": "INTERNAL",
|
|
85
|
+
"title": "Internal error",
|
|
86
|
+
"cli_message": "An unexpected internal error occurred.",
|
|
87
|
+
"recovery": [
|
|
88
|
+
"Run: slm doctor",
|
|
89
|
+
"If the issue persists, file an issue with the log excerpt.",
|
|
90
|
+
],
|
|
91
|
+
"exit_code": 8,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def lookup(code: str | ErrorCode) -> ErrorEntry:
|
|
97
|
+
key = code.value if isinstance(code, ErrorCode) else code
|
|
98
|
+
if key not in CATALOG:
|
|
99
|
+
return CATALOG[ErrorCode.INTERNAL.value]
|
|
100
|
+
return CATALOG[key]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_cli(code: str | ErrorCode, detail: str | None = None) -> str:
|
|
104
|
+
entry = lookup(code)
|
|
105
|
+
lines = [f"\u2717 {entry['title']}"]
|
|
106
|
+
lines.append(f" {entry['cli_message']}")
|
|
107
|
+
if detail:
|
|
108
|
+
lines.append(f" Detail: {detail}")
|
|
109
|
+
if entry["recovery"]:
|
|
110
|
+
lines.append(" Try:")
|
|
111
|
+
for step in entry["recovery"]:
|
|
112
|
+
lines.append(f" - {step}")
|
|
113
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Uniform error envelope for queue-backed callers.
|
|
6
|
+
|
|
7
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ErrorCode(str, Enum):
|
|
17
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
18
|
+
QUEUE_FULL = "QUEUE_FULL"
|
|
19
|
+
TIMEOUT = "TIMEOUT"
|
|
20
|
+
CANCELLED = "CANCELLED"
|
|
21
|
+
DEAD_LETTER = "DEAD_LETTER"
|
|
22
|
+
DAEMON_DOWN = "DAEMON_DOWN"
|
|
23
|
+
INTERNAL = "INTERNAL"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_HTTP_STATUS: dict[ErrorCode, int] = {
|
|
27
|
+
ErrorCode.RATE_LIMITED: 429,
|
|
28
|
+
ErrorCode.QUEUE_FULL: 503,
|
|
29
|
+
ErrorCode.TIMEOUT: 504,
|
|
30
|
+
ErrorCode.DEAD_LETTER: 504,
|
|
31
|
+
ErrorCode.CANCELLED: 499,
|
|
32
|
+
ErrorCode.DAEMON_DOWN: 502,
|
|
33
|
+
ErrorCode.INTERNAL: 500,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def http_status_for(code: ErrorCode) -> int:
|
|
38
|
+
return _HTTP_STATUS[code]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_error_envelope(
|
|
42
|
+
code: ErrorCode,
|
|
43
|
+
message: str,
|
|
44
|
+
*,
|
|
45
|
+
request_id: str | None = None,
|
|
46
|
+
retry_after_ms: int | None = None,
|
|
47
|
+
**extras: Any,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Build a JSON-serialisable error envelope."""
|
|
50
|
+
env: dict[str, Any] = {
|
|
51
|
+
"ok": False,
|
|
52
|
+
"error_code": code.value,
|
|
53
|
+
"error": message,
|
|
54
|
+
"request_id": request_id,
|
|
55
|
+
"retry_after_ms": retry_after_ms,
|
|
56
|
+
}
|
|
57
|
+
for k, v in extras.items():
|
|
58
|
+
if k not in env:
|
|
59
|
+
env[k] = v
|
|
60
|
+
return env
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Cross-platform exclusive file lock via portalocker.
|
|
6
|
+
|
|
7
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import portalocker
|
|
20
|
+
_HAS_PORTALOCKER = True
|
|
21
|
+
except ImportError: # pragma: no cover
|
|
22
|
+
portalocker = None # type: ignore[assignment]
|
|
23
|
+
_HAS_PORTALOCKER = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LockHeldError(RuntimeError):
|
|
27
|
+
"""Raised when the lock cannot be acquired (held by another holder)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FallbackLock:
|
|
31
|
+
"""Thread-local mutex used when portalocker is unavailable."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def acquire(self, timeout: float) -> bool:
|
|
37
|
+
return self._lock.acquire(timeout=timeout)
|
|
38
|
+
|
|
39
|
+
def release(self) -> None:
|
|
40
|
+
self._lock.release()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_fallback_registry: dict[str, _FallbackLock] = {}
|
|
44
|
+
_fallback_registry_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
# Track fds held by this process so a double-acquire in the same process
|
|
47
|
+
# is rejected immediately (portalocker on POSIX is permissive on same fd).
|
|
48
|
+
_held_in_process: set[str] = set()
|
|
49
|
+
_held_lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def exclusive_lock(path: Path, timeout_s: float = 0.0) -> Iterator[int]:
|
|
54
|
+
"""Acquire an exclusive file lock. Raises LockHeldError on contention."""
|
|
55
|
+
path_str = str(path)
|
|
56
|
+
with _held_lock:
|
|
57
|
+
if path_str in _held_in_process:
|
|
58
|
+
raise LockHeldError(f"{path} already locked by this process")
|
|
59
|
+
_held_in_process.add(path_str)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
if _HAS_PORTALOCKER:
|
|
63
|
+
fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
64
|
+
try:
|
|
65
|
+
try:
|
|
66
|
+
portalocker.lock(
|
|
67
|
+
fd, portalocker.LOCK_EX | portalocker.LOCK_NB,
|
|
68
|
+
)
|
|
69
|
+
except portalocker.LockException as exc:
|
|
70
|
+
os.close(fd)
|
|
71
|
+
raise LockHeldError(f"{path} is locked") from exc
|
|
72
|
+
try:
|
|
73
|
+
yield fd
|
|
74
|
+
finally:
|
|
75
|
+
try:
|
|
76
|
+
portalocker.unlock(fd)
|
|
77
|
+
finally:
|
|
78
|
+
os.close(fd)
|
|
79
|
+
except LockHeldError:
|
|
80
|
+
raise
|
|
81
|
+
else: # pragma: no cover — fallback path
|
|
82
|
+
with _fallback_registry_lock:
|
|
83
|
+
lk = _fallback_registry.setdefault(path_str, _FallbackLock())
|
|
84
|
+
if not lk.acquire(timeout=timeout_s):
|
|
85
|
+
raise LockHeldError(f"{path} is locked (fallback)")
|
|
86
|
+
try:
|
|
87
|
+
yield -1
|
|
88
|
+
finally:
|
|
89
|
+
lk.release()
|
|
90
|
+
finally:
|
|
91
|
+
with _held_lock:
|
|
92
|
+
_held_in_process.discard(path_str)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Tick-based watchdog that fires once when a cooperative loop goes silent.
|
|
6
|
+
|
|
7
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LoopWatchdog:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
stale_threshold_s: float,
|
|
21
|
+
on_stale: Optional[Callable[[float], None]] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._threshold = stale_threshold_s
|
|
24
|
+
self._on_stale = on_stale
|
|
25
|
+
self._last_tick = time.monotonic()
|
|
26
|
+
self._fired = False
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def tick(self) -> None:
|
|
30
|
+
with self._lock:
|
|
31
|
+
self._last_tick = time.monotonic()
|
|
32
|
+
self._fired = False
|
|
33
|
+
|
|
34
|
+
def age_s(self) -> float:
|
|
35
|
+
with self._lock:
|
|
36
|
+
return time.monotonic() - self._last_tick
|
|
37
|
+
|
|
38
|
+
def is_stale(self) -> bool:
|
|
39
|
+
return self.age_s() >= self._threshold
|
|
40
|
+
|
|
41
|
+
def check(self) -> bool:
|
|
42
|
+
"""Fire callback once if stale; return True if just fired."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
age = time.monotonic() - self._last_tick
|
|
45
|
+
if age < self._threshold or self._fired:
|
|
46
|
+
return False
|
|
47
|
+
self._fired = True
|
|
48
|
+
cb = self._on_stale
|
|
49
|
+
if cb is not None:
|
|
50
|
+
cb(age)
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
def run_forever(self, stop: threading.Event, interval_s: float = 1.0) -> None:
|
|
54
|
+
while not stop.is_set():
|
|
55
|
+
self.check()
|
|
56
|
+
stop.wait(timeout=interval_s)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Weighted fair scheduler for high/low job lanes.
|
|
6
|
+
|
|
7
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
Lane = Literal["high", "low"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WFQScheduler:
|
|
19
|
+
"""Deficit-round-robin-ish scheduler over two lanes.
|
|
20
|
+
|
|
21
|
+
Tracks served counts and picks the lane whose ratio is furthest
|
|
22
|
+
below its target share. Approximates the target ratio over any
|
|
23
|
+
rolling window without requiring a time source.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, high_weight: int = 70, low_weight: int = 30) -> None:
|
|
27
|
+
if high_weight <= 0 or low_weight <= 0:
|
|
28
|
+
raise ValueError("weights must be positive")
|
|
29
|
+
self.high_weight = high_weight
|
|
30
|
+
self.low_weight = low_weight
|
|
31
|
+
self._served = {"high": 0, "low": 0}
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def pick_lane(self, *, has_high: bool, has_low: bool) -> Lane | None:
|
|
35
|
+
if not has_high and not has_low:
|
|
36
|
+
return None
|
|
37
|
+
if has_high and not has_low:
|
|
38
|
+
return "high"
|
|
39
|
+
if has_low and not has_high:
|
|
40
|
+
return "low"
|
|
41
|
+
with self._lock:
|
|
42
|
+
total = self._served["high"] + self._served["low"]
|
|
43
|
+
if total == 0:
|
|
44
|
+
return "high"
|
|
45
|
+
total_weight = self.high_weight + self.low_weight
|
|
46
|
+
high_target = self.high_weight / total_weight
|
|
47
|
+
low_target = self.low_weight / total_weight
|
|
48
|
+
high_ratio = self._served["high"] / total
|
|
49
|
+
low_ratio = self._served["low"] / total
|
|
50
|
+
# Pick whichever lane is further below its target
|
|
51
|
+
high_deficit = high_target - high_ratio
|
|
52
|
+
low_deficit = low_target - low_ratio
|
|
53
|
+
return "high" if high_deficit >= low_deficit else "low"
|
|
54
|
+
|
|
55
|
+
def record_served(self, lane: Lane) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._served[lane] += 1
|
|
58
|
+
|
|
59
|
+
def snapshot(self) -> dict[str, int]:
|
|
60
|
+
with self._lock:
|
|
61
|
+
return dict(self._served)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Queue-backed dispatcher coordinator.
|
|
6
|
+
|
|
7
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from superlocalmemory.core import rate_limit as rl
|
|
17
|
+
from superlocalmemory.core import recall_queue as rq
|
|
18
|
+
from superlocalmemory.core.engine_lock import EngineRWLock
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QueueDispatcher:
|
|
22
|
+
"""Coordinates rate-limit check, enqueue, and poll for callers."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
db_path: Path | str,
|
|
28
|
+
global_rps: float = 100.0,
|
|
29
|
+
per_pid_rps: float = 30.0,
|
|
30
|
+
per_agent_rps: float = 10.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.queue = rq.RecallQueue(db_path=db_path)
|
|
33
|
+
self.engine_lock = EngineRWLock()
|
|
34
|
+
self._rate = rl.LayeredRateLimiter(
|
|
35
|
+
global_rps=global_rps,
|
|
36
|
+
per_pid_rps=per_pid_rps,
|
|
37
|
+
per_agent_rps=per_agent_rps,
|
|
38
|
+
)
|
|
39
|
+
# Module handles kept for test introspection.
|
|
40
|
+
self.rl = rl
|
|
41
|
+
self.rq = rq
|
|
42
|
+
|
|
43
|
+
def _check_rate(self, *, pid: int, agent_id: str | None) -> None:
|
|
44
|
+
self._rate.check_and_consume(pid=pid, agent_id=agent_id)
|
|
45
|
+
|
|
46
|
+
def dispatch(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
query: str,
|
|
50
|
+
limit_n: int,
|
|
51
|
+
mode: str,
|
|
52
|
+
agent_id: str,
|
|
53
|
+
session_id: str,
|
|
54
|
+
tenant_id: str = "",
|
|
55
|
+
namespace: str = "",
|
|
56
|
+
priority: str = "high",
|
|
57
|
+
stall_timeout_s: float = 25.0,
|
|
58
|
+
timeout_s: float = 30.0,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
self._check_rate(pid=os.getpid(), agent_id=agent_id)
|
|
61
|
+
rid = self.queue.enqueue(
|
|
62
|
+
query=query, limit_n=limit_n, mode=mode,
|
|
63
|
+
agent_id=agent_id, session_id=session_id,
|
|
64
|
+
tenant_id=tenant_id, namespace=namespace,
|
|
65
|
+
priority=priority, stall_timeout_s=stall_timeout_s,
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
return self.queue.poll_result(rid, timeout_s=timeout_s)
|
|
69
|
+
finally:
|
|
70
|
+
self.queue.unsubscribe(rid)
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self.queue.close()
|