|
3 | 3 | """ |
4 | 4 |
|
5 | 5 | import logging |
| 6 | +import sqlite3 |
| 7 | +import time |
6 | 8 | from typing import Protocol |
7 | 9 |
|
8 | 10 | import msgspec |
@@ -108,3 +110,105 @@ def clear_session(self, session_id: str) -> None: |
108 | 110 | queue_key = self._get_queue_key(session_id) |
109 | 111 | self.redis.delete(queue_key) |
110 | 112 | logger.debug(f"Redis: Clearing session: {session_id}") |
| 113 | + |
| 114 | + |
| 115 | +class SQLiteResponseQueue(ResponseQueueProtocol): |
| 116 | + """ |
| 117 | + A SQLite-backed queue implementation for MCP responses. |
| 118 | + Each session is a logical queue keyed by `session_id`. |
| 119 | +
|
| 120 | + Uses a simple table and transactional pop to ensure single-delivery. |
| 121 | + """ |
| 122 | + |
| 123 | + def __init__(self, db_path: str = ":memory:"): |
| 124 | + """Initialize and create tables if needed. |
| 125 | +
|
| 126 | + Args: |
| 127 | + db_path: Path to the SQLite database file. Defaults to in-memory. |
| 128 | + """ |
| 129 | + # isolation_level=None -> autocommit mode so we can manage BEGIN/COMMIT explicitly |
| 130 | + self.conn = sqlite3.connect( |
| 131 | + db_path, timeout=30, check_same_thread=False, isolation_level=None |
| 132 | + ) |
| 133 | + self.conn.execute( |
| 134 | + """ |
| 135 | + CREATE TABLE IF NOT EXISTS response_queue ( |
| 136 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 137 | + session_id TEXT NOT NULL, |
| 138 | + payload TEXT NOT NULL, |
| 139 | + created_at REAL DEFAULT (strftime('%s','now')) |
| 140 | + ) |
| 141 | + """ |
| 142 | + ) |
| 143 | + self.conn.execute( |
| 144 | + """ |
| 145 | + CREATE INDEX IF NOT EXISTS idx_response_queue_session_id |
| 146 | + ON response_queue(session_id, id) |
| 147 | + """ |
| 148 | + ) |
| 149 | + |
| 150 | + def push_response(self, session_id: str, response: MCPResponse) -> None: |
| 151 | + """Insert a response payload for a session.""" |
| 152 | + payload = msgspec.json.encode(response).decode() |
| 153 | + logger.debug(f"SQLite: Saving response for session: {session_id}: {payload}") |
| 154 | + self.conn.execute( |
| 155 | + "INSERT INTO response_queue (session_id, payload) VALUES (?, ?)", |
| 156 | + (session_id, payload), |
| 157 | + ) |
| 158 | + |
| 159 | + def _pop_one(self, session_id: str) -> str | None: |
| 160 | + """Atomically pop the oldest payload for the session, if any.""" |
| 161 | + try: |
| 162 | + cur = self.conn.cursor() |
| 163 | + cur.execute("BEGIN IMMEDIATE") |
| 164 | + row = cur.execute( |
| 165 | + "SELECT id, payload FROM response_queue WHERE session_id = ? ORDER BY id LIMIT 1", |
| 166 | + (session_id,), |
| 167 | + ).fetchone() |
| 168 | + if not row: |
| 169 | + cur.execute("ROLLBACK") |
| 170 | + return None |
| 171 | + row_id, payload = row |
| 172 | + cur.execute("DELETE FROM response_queue WHERE id = ?", (row_id,)) |
| 173 | + cur.execute("COMMIT") |
| 174 | + return payload |
| 175 | + except sqlite3.Error as e: |
| 176 | + logger.error(f"SQLite pop error: {e}") |
| 177 | + try: |
| 178 | + self.conn.execute("ROLLBACK") |
| 179 | + except Exception: |
| 180 | + pass |
| 181 | + return None |
| 182 | + |
| 183 | + def wait_for_response(self, session_id: str, timeout: float | None = None) -> str | None: |
| 184 | + """ |
| 185 | + Wait for and pop the next response for a session. |
| 186 | +
|
| 187 | + If timeout is None, waits indefinitely using polling. |
| 188 | + If timeout is 0, returns immediately if none available. |
| 189 | + """ |
| 190 | + # Immediate, non-blocking attempt |
| 191 | + payload = self._pop_one(session_id) |
| 192 | + if payload is not None: |
| 193 | + return payload |
| 194 | + |
| 195 | + if timeout == 0: |
| 196 | + return None |
| 197 | + |
| 198 | + # Poll until available or timeout |
| 199 | + start = time.time() |
| 200 | + while True: |
| 201 | + payload = self._pop_one(session_id) |
| 202 | + if payload is not None: |
| 203 | + return payload |
| 204 | + if timeout is not None and (time.time() - start) >= timeout: |
| 205 | + return None |
| 206 | + time.sleep(0.1) |
| 207 | + |
| 208 | + def clear_session(self, session_id: str) -> None: |
| 209 | + """Remove all queued items for a session.""" |
| 210 | + logger.debug(f"SQLite: Clearing session: {session_id}") |
| 211 | + self.conn.execute( |
| 212 | + "DELETE FROM response_queue WHERE session_id = ?", |
| 213 | + (session_id,), |
| 214 | + ) |
0 commit comments