synckey 0.1.0__py3-none-any.whl
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.
- synckey/__init__.py +3 -0
- synckey/__main__.py +4 -0
- synckey/bucket.py +140 -0
- synckey/cli.py +1481 -0
- synckey/config.py +150 -0
- synckey/context.py +75 -0
- synckey/crypto.py +41 -0
- synckey/db.py +426 -0
- synckey/deferred.py +41 -0
- synckey/keypool.py +139 -0
- synckey/prefs.py +82 -0
- synckey/providers.py +189 -0
- synckey/router.py +291 -0
- synckey/server.py +672 -0
- synckey/state.py +152 -0
- synckey/tiers.py +157 -0
- synckey/usage.py +99 -0
- synckey-0.1.0.dist-info/METADATA +799 -0
- synckey-0.1.0.dist-info/RECORD +21 -0
- synckey-0.1.0.dist-info/WHEEL +4 -0
- synckey-0.1.0.dist-info/entry_points.txt +2 -0
synckey/state.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Durable key health state.
|
|
2
|
+
|
|
3
|
+
In-memory dict is the hot path (zero I/O on reads). SQLite is the durability
|
|
4
|
+
layer: written immediately on every health transition so a restart never forgets
|
|
5
|
+
which keys are cooling or dead.
|
|
6
|
+
|
|
7
|
+
Health transitions:
|
|
8
|
+
LIVE -> COOLING on 429 (auto-recovers after cooldown_until)
|
|
9
|
+
LIVE -> DEAD on 401/403 or explicit quota exhaustion
|
|
10
|
+
COOLING -> LIVE automatically when cooldown_until passes (no write needed)
|
|
11
|
+
DEAD -> LIVE only via `synckey key enable` (user action)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import IntEnum
|
|
19
|
+
|
|
20
|
+
import sqlite3
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Health(IntEnum):
|
|
25
|
+
LIVE = 0
|
|
26
|
+
COOLING = 1
|
|
27
|
+
DEAD = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class KeyState:
|
|
32
|
+
health: Health = Health.LIVE
|
|
33
|
+
cooldown_until: float = 0.0
|
|
34
|
+
dead_reason: str = ""
|
|
35
|
+
rpm_observed: float | None = None
|
|
36
|
+
tpm_observed: float | None = None
|
|
37
|
+
|
|
38
|
+
def is_available(self) -> bool:
|
|
39
|
+
if self.health == Health.DEAD:
|
|
40
|
+
return False
|
|
41
|
+
if self.health == Health.COOLING and time.time() < self.cooldown_until:
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def cooldown_remaining(self) -> float:
|
|
46
|
+
if self.health == Health.COOLING:
|
|
47
|
+
return max(0.0, self.cooldown_until - time.time())
|
|
48
|
+
return 0.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_CREATE = """
|
|
52
|
+
CREATE TABLE IF NOT EXISTS key_state (
|
|
53
|
+
key_id INTEGER PRIMARY KEY,
|
|
54
|
+
health INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
cooldown_until REAL NOT NULL DEFAULT 0,
|
|
56
|
+
dead_reason TEXT NOT NULL DEFAULT '',
|
|
57
|
+
rpm_observed REAL,
|
|
58
|
+
tpm_observed REAL,
|
|
59
|
+
updated_at REAL NOT NULL DEFAULT 0
|
|
60
|
+
);
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_UPSERT = """
|
|
64
|
+
INSERT INTO key_state(key_id, health, cooldown_until, dead_reason, rpm_observed, tpm_observed, updated_at)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
66
|
+
ON CONFLICT(key_id) DO UPDATE SET
|
|
67
|
+
health = excluded.health,
|
|
68
|
+
cooldown_until = excluded.cooldown_until,
|
|
69
|
+
dead_reason = excluded.dead_reason,
|
|
70
|
+
rpm_observed = excluded.rpm_observed,
|
|
71
|
+
tpm_observed = excluded.tpm_observed,
|
|
72
|
+
updated_at = excluded.updated_at
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class StateStore:
|
|
77
|
+
"""In-memory key health store backed by SQLite for durability."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, db_path: Path):
|
|
80
|
+
self._mem: dict[int, KeyState] = {}
|
|
81
|
+
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
82
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
83
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
84
|
+
self._conn.execute(_CREATE)
|
|
85
|
+
self._conn.commit()
|
|
86
|
+
self._load()
|
|
87
|
+
|
|
88
|
+
def _load(self) -> None:
|
|
89
|
+
now = time.time()
|
|
90
|
+
for row in self._conn.execute("SELECT * FROM key_state").fetchall():
|
|
91
|
+
key_id, health, cooldown_until, dead_reason, rpm_obs, tpm_obs, _ = row
|
|
92
|
+
h = Health(health)
|
|
93
|
+
# Expired cooldowns auto-recover to LIVE on load.
|
|
94
|
+
if h == Health.COOLING and cooldown_until <= now:
|
|
95
|
+
h = Health.LIVE
|
|
96
|
+
cooldown_until = 0.0
|
|
97
|
+
self._mem[key_id] = KeyState(
|
|
98
|
+
health=h,
|
|
99
|
+
cooldown_until=cooldown_until,
|
|
100
|
+
dead_reason=dead_reason or "",
|
|
101
|
+
rpm_observed=rpm_obs,
|
|
102
|
+
tpm_observed=tpm_obs,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _persist(self, key_id: int) -> None:
|
|
106
|
+
st = self._mem[key_id]
|
|
107
|
+
self._conn.execute(
|
|
108
|
+
_UPSERT,
|
|
109
|
+
(
|
|
110
|
+
key_id,
|
|
111
|
+
int(st.health),
|
|
112
|
+
st.cooldown_until,
|
|
113
|
+
st.dead_reason,
|
|
114
|
+
st.rpm_observed,
|
|
115
|
+
st.tpm_observed,
|
|
116
|
+
time.time(),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
self._conn.commit()
|
|
120
|
+
|
|
121
|
+
def get(self, key_id: int) -> KeyState:
|
|
122
|
+
return self._mem.get(key_id, KeyState())
|
|
123
|
+
|
|
124
|
+
def set_cooling(self, key_id: int, until: float, rpm_observed: float | None = None) -> None:
|
|
125
|
+
st = self._mem.get(key_id, KeyState())
|
|
126
|
+
st.health = Health.COOLING
|
|
127
|
+
st.cooldown_until = until
|
|
128
|
+
if rpm_observed is not None:
|
|
129
|
+
st.rpm_observed = rpm_observed
|
|
130
|
+
self._mem[key_id] = st
|
|
131
|
+
self._persist(key_id)
|
|
132
|
+
|
|
133
|
+
def set_dead(self, key_id: int, reason: str) -> None:
|
|
134
|
+
st = self._mem.get(key_id, KeyState())
|
|
135
|
+
st.health = Health.DEAD
|
|
136
|
+
st.dead_reason = reason
|
|
137
|
+
self._mem[key_id] = st
|
|
138
|
+
self._persist(key_id)
|
|
139
|
+
|
|
140
|
+
def set_live(self, key_id: int) -> None:
|
|
141
|
+
st = self._mem.get(key_id, KeyState())
|
|
142
|
+
if st.health != Health.LIVE:
|
|
143
|
+
st.health = Health.LIVE
|
|
144
|
+
st.cooldown_until = 0.0
|
|
145
|
+
self._mem[key_id] = st
|
|
146
|
+
self._persist(key_id)
|
|
147
|
+
|
|
148
|
+
def all(self) -> dict[int, KeyState]:
|
|
149
|
+
return dict(self._mem)
|
|
150
|
+
|
|
151
|
+
def close(self) -> None:
|
|
152
|
+
self._conn.close()
|
synckey/tiers.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Model tier catalog, pricing, and tier-floored fallback logic.
|
|
2
|
+
|
|
3
|
+
Tiers:
|
|
4
|
+
FRONTIER (4) best quality, most expensive (Opus, GPT-5, o3)
|
|
5
|
+
HIGH (3) capable, premium (Sonnet, GPT-4o, o1)
|
|
6
|
+
MID (2) fast, affordable (Haiku, GPT-4o-mini, Flash, 70b)
|
|
7
|
+
LOW (1) budget, high-volume (8b, Gemma, Qwen)
|
|
8
|
+
|
|
9
|
+
A request for a frontier model will never fall back below FRONTIER unless the
|
|
10
|
+
caller explicitly sets X-Quality-Floor: high (or lower). This is the Opus ->
|
|
11
|
+
GPT-5 guarantee, never Opus -> oss-llama.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
FRONTIER = 4
|
|
19
|
+
HIGH = 3
|
|
20
|
+
MID = 2
|
|
21
|
+
LOW = 1
|
|
22
|
+
|
|
23
|
+
TIER_NAMES = {FRONTIER: "frontier", HIGH: "high", MID: "mid", LOW: "low"}
|
|
24
|
+
TIER_BY_NAME = {v: k for k, v in TIER_NAMES.items()}
|
|
25
|
+
|
|
26
|
+
# (pattern, tier) -- checked in order; more-specific patterns first.
|
|
27
|
+
# gpt-4o-mini must come before gpt-4o to avoid premature match.
|
|
28
|
+
_PATTERNS: list[tuple[re.Pattern, int]] = [
|
|
29
|
+
(re.compile(r"claude-opus", re.I), FRONTIER),
|
|
30
|
+
(re.compile(r"gpt-5", re.I), FRONTIER),
|
|
31
|
+
(re.compile(r"^o3", re.I), FRONTIER),
|
|
32
|
+
(re.compile(r"gemini-2\.5-pro", re.I), FRONTIER),
|
|
33
|
+
(re.compile(r"grok-3", re.I), FRONTIER),
|
|
34
|
+
(re.compile(r"magistral", re.I), FRONTIER),
|
|
35
|
+
|
|
36
|
+
(re.compile(r"claude-sonnet", re.I), HIGH),
|
|
37
|
+
(re.compile(r"gpt-4\.1(?!-mini)", re.I), HIGH),
|
|
38
|
+
(re.compile(r"gpt-4o(?!-mini)", re.I), HIGH),
|
|
39
|
+
(re.compile(r"^o1", re.I), HIGH),
|
|
40
|
+
(re.compile(r"gemini-2\.0-pro", re.I), HIGH),
|
|
41
|
+
(re.compile(r"gemini-1\.5-pro", re.I), HIGH),
|
|
42
|
+
(re.compile(r"mistral-large", re.I), HIGH),
|
|
43
|
+
(re.compile(r"grok-2", re.I), HIGH),
|
|
44
|
+
(re.compile(r"deepseek-r1", re.I), HIGH),
|
|
45
|
+
|
|
46
|
+
(re.compile(r"claude-haiku", re.I), MID),
|
|
47
|
+
(re.compile(r"gpt-4o-mini", re.I), MID),
|
|
48
|
+
(re.compile(r"gpt-4\.1-mini", re.I), MID),
|
|
49
|
+
(re.compile(r"gemini-2\.0-flash", re.I), MID),
|
|
50
|
+
(re.compile(r"gemini-flash", re.I), MID),
|
|
51
|
+
(re.compile(r"llama-3\.3-70b", re.I), MID),
|
|
52
|
+
(re.compile(r"llama-3\.1-70b", re.I), MID),
|
|
53
|
+
(re.compile(r"llama-3-70b", re.I), MID),
|
|
54
|
+
(re.compile(r"mistral-medium", re.I), MID),
|
|
55
|
+
(re.compile(r"codestral", re.I), MID),
|
|
56
|
+
(re.compile(r"deepseek-chat", re.I), MID),
|
|
57
|
+
(re.compile(r"command-r-plus", re.I), MID),
|
|
58
|
+
(re.compile(r"command-r(?!-plus)", re.I), MID),
|
|
59
|
+
(re.compile(r"qwen-72b", re.I), MID),
|
|
60
|
+
(re.compile(r"mixtral", re.I), MID),
|
|
61
|
+
(re.compile(r"llama3\.1-70b", re.I), MID),
|
|
62
|
+
|
|
63
|
+
(re.compile(r"gemma", re.I), LOW),
|
|
64
|
+
(re.compile(r"llama-3\.1-8b", re.I), LOW),
|
|
65
|
+
(re.compile(r"llama-3-8b", re.I), LOW),
|
|
66
|
+
(re.compile(r"llama3\.1-8b", re.I), LOW),
|
|
67
|
+
(re.compile(r"qwen(?!-72b)", re.I), LOW),
|
|
68
|
+
(re.compile(r"mistral-7b", re.I), LOW),
|
|
69
|
+
(re.compile(r"mistral-0", re.I), LOW),
|
|
70
|
+
(re.compile(r"command-light", re.I), LOW),
|
|
71
|
+
(re.compile(r"gemini-1\.5-flash-8", re.I), LOW),
|
|
72
|
+
(re.compile(r"phi-3", re.I), LOW),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# (input $/M tokens, output $/M tokens) -- best-effort, users can override
|
|
76
|
+
_PRICES: list[tuple[re.Pattern, tuple[float, float]]] = [
|
|
77
|
+
(re.compile(r"claude-opus-4-8", re.I), (15.00, 75.00)),
|
|
78
|
+
(re.compile(r"claude-opus", re.I), (15.00, 75.00)),
|
|
79
|
+
(re.compile(r"claude-sonnet-4-6", re.I), ( 3.00, 15.00)),
|
|
80
|
+
(re.compile(r"claude-sonnet", re.I), ( 3.00, 15.00)),
|
|
81
|
+
(re.compile(r"claude-haiku-4-5", re.I), ( 0.80, 4.00)),
|
|
82
|
+
(re.compile(r"claude-haiku", re.I), ( 0.80, 4.00)),
|
|
83
|
+
(re.compile(r"gpt-5", re.I), (10.00, 40.00)),
|
|
84
|
+
(re.compile(r"gpt-4\.1-mini", re.I), ( 0.40, 1.60)),
|
|
85
|
+
(re.compile(r"gpt-4\.1", re.I), ( 2.00, 8.00)),
|
|
86
|
+
(re.compile(r"gpt-4o-mini", re.I), ( 0.15, 0.60)),
|
|
87
|
+
(re.compile(r"gpt-4o", re.I), ( 5.00, 15.00)),
|
|
88
|
+
(re.compile(r"^o3", re.I), (10.00, 40.00)),
|
|
89
|
+
(re.compile(r"^o1-mini", re.I), ( 3.00, 12.00)),
|
|
90
|
+
(re.compile(r"^o1", re.I), (15.00, 60.00)),
|
|
91
|
+
(re.compile(r"gemini-2\.5-pro", re.I), ( 3.50, 10.50)),
|
|
92
|
+
(re.compile(r"gemini-2\.0-flash", re.I), ( 0.10, 0.40)),
|
|
93
|
+
(re.compile(r"gemini-flash", re.I), ( 0.10, 0.40)),
|
|
94
|
+
(re.compile(r"gemini-1\.5-pro", re.I), ( 3.50, 10.50)),
|
|
95
|
+
(re.compile(r"gemini-1\.5-flash", re.I), ( 0.075, 0.30)),
|
|
96
|
+
(re.compile(r"llama-3\.3-70b", re.I), ( 0.59, 0.79)),
|
|
97
|
+
(re.compile(r"llama-3\.1-70b", re.I), ( 0.52, 0.75)),
|
|
98
|
+
(re.compile(r"llama-3\.1-8b", re.I), ( 0.05, 0.08)),
|
|
99
|
+
(re.compile(r"llama3\.1-70b", re.I), ( 0.52, 0.75)),
|
|
100
|
+
(re.compile(r"llama3\.1-8b", re.I), ( 0.05, 0.08)),
|
|
101
|
+
(re.compile(r"mistral-large", re.I), ( 3.00, 9.00)),
|
|
102
|
+
(re.compile(r"mistral-medium", re.I), ( 2.70, 8.10)),
|
|
103
|
+
(re.compile(r"codestral", re.I), ( 0.30, 0.90)),
|
|
104
|
+
(re.compile(r"deepseek-r1", re.I), ( 0.55, 2.19)),
|
|
105
|
+
(re.compile(r"deepseek-chat", re.I), ( 0.27, 1.10)),
|
|
106
|
+
(re.compile(r"command-r-plus", re.I), ( 2.50, 10.00)),
|
|
107
|
+
(re.compile(r"command-r", re.I), ( 0.15, 0.60)),
|
|
108
|
+
(re.compile(r"grok-3", re.I), ( 3.00, 15.00)),
|
|
109
|
+
(re.compile(r"grok-2", re.I), ( 2.00, 10.00)),
|
|
110
|
+
(re.compile(r"mixtral", re.I), ( 0.24, 0.24)),
|
|
111
|
+
(re.compile(r"gemma", re.I), ( 0.10, 0.10)),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
# User-configurable overrides; set by load_overrides().
|
|
115
|
+
_tier_overrides: dict[str, int] = {}
|
|
116
|
+
_price_overrides: dict[str, tuple[float, float]] = {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_overrides(tiers: dict[str, str], prices: dict[str, list]) -> None:
|
|
120
|
+
"""Load user-defined tier and price overrides from config."""
|
|
121
|
+
for model, tier_name in tiers.items():
|
|
122
|
+
t = TIER_BY_NAME.get(tier_name.lower())
|
|
123
|
+
if t is not None:
|
|
124
|
+
_tier_overrides[model.lower()] = t
|
|
125
|
+
for model, pair in prices.items():
|
|
126
|
+
if len(pair) == 2:
|
|
127
|
+
_price_overrides[model.lower()] = (float(pair[0]), float(pair[1]))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def model_tier(model: str) -> int | None:
|
|
131
|
+
"""Return the capability tier for a model name, or None if unknown."""
|
|
132
|
+
key = model.lower()
|
|
133
|
+
if key in _tier_overrides:
|
|
134
|
+
return _tier_overrides[key]
|
|
135
|
+
for rx, tier in _PATTERNS:
|
|
136
|
+
if rx.search(model):
|
|
137
|
+
return tier
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def model_price(model: str) -> tuple[float, float] | None:
|
|
142
|
+
"""Return (input, output) price in $/M tokens, or None if unknown."""
|
|
143
|
+
key = model.lower()
|
|
144
|
+
if key in _price_overrides:
|
|
145
|
+
return _price_overrides[key]
|
|
146
|
+
for rx, price in _PRICES:
|
|
147
|
+
if rx.search(model):
|
|
148
|
+
return price
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cost_usd(model: str, prompt_tokens: int, completion_tokens: int) -> float | None:
|
|
153
|
+
"""Compute cost in USD for a completed request, or None if price unknown."""
|
|
154
|
+
price = model_price(model)
|
|
155
|
+
if price is None:
|
|
156
|
+
return None
|
|
157
|
+
return (prompt_tokens * price[0] + completion_tokens * price[1]) / 1_000_000
|
synckey/usage.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Batched off-thread usage writer.
|
|
2
|
+
|
|
3
|
+
The gateway calls record() once per request. Non-blocking: the row queues and
|
|
4
|
+
a background thread flushes in batches. One fsync per batch instead of per request.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import queue
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .db import USAGE_INSERT, tune
|
|
16
|
+
|
|
17
|
+
Row = tuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UsageWriter:
|
|
21
|
+
def __init__(self, path: Path, batch_size: int = 200, flush_interval: float = 0.5):
|
|
22
|
+
self.path = Path(path)
|
|
23
|
+
self.batch_size = batch_size
|
|
24
|
+
self.flush_interval = flush_interval
|
|
25
|
+
self.queue: queue.Queue[Row] = queue.Queue(maxsize=20000)
|
|
26
|
+
self._stop = threading.Event()
|
|
27
|
+
self._thread: threading.Thread | None = None
|
|
28
|
+
|
|
29
|
+
def start(self) -> None:
|
|
30
|
+
self._thread = threading.Thread(target=self._run, name="synckey-usage", daemon=True)
|
|
31
|
+
self._thread.start()
|
|
32
|
+
|
|
33
|
+
def record(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
provider: str,
|
|
37
|
+
model: str,
|
|
38
|
+
key_id: int | None,
|
|
39
|
+
prompt_tokens: int = 0,
|
|
40
|
+
completion_tokens: int = 0,
|
|
41
|
+
total_tokens: int = 0,
|
|
42
|
+
cost_usd: float | None = None,
|
|
43
|
+
status_code: int = 0,
|
|
44
|
+
latency_ms: int = 0,
|
|
45
|
+
stream: bool = False,
|
|
46
|
+
tier: int | None = None,
|
|
47
|
+
error: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
row = (
|
|
50
|
+
time.time(),
|
|
51
|
+
provider,
|
|
52
|
+
model,
|
|
53
|
+
key_id,
|
|
54
|
+
prompt_tokens,
|
|
55
|
+
completion_tokens,
|
|
56
|
+
total_tokens,
|
|
57
|
+
cost_usd,
|
|
58
|
+
status_code,
|
|
59
|
+
latency_ms,
|
|
60
|
+
1 if stream else 0,
|
|
61
|
+
tier,
|
|
62
|
+
error,
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
self.queue.put_nowait(row)
|
|
66
|
+
except queue.Full:
|
|
67
|
+
pass # drop metrics under extreme backpressure
|
|
68
|
+
|
|
69
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
70
|
+
self._stop.set()
|
|
71
|
+
if self._thread:
|
|
72
|
+
self._thread.join(timeout=timeout)
|
|
73
|
+
|
|
74
|
+
def _run(self) -> None:
|
|
75
|
+
conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
|
76
|
+
tune(conn)
|
|
77
|
+
try:
|
|
78
|
+
while True:
|
|
79
|
+
batch = self._collect()
|
|
80
|
+
if batch:
|
|
81
|
+
conn.executemany(USAGE_INSERT, batch)
|
|
82
|
+
conn.commit()
|
|
83
|
+
elif self._stop.is_set() and self.queue.empty():
|
|
84
|
+
return
|
|
85
|
+
finally:
|
|
86
|
+
conn.close()
|
|
87
|
+
|
|
88
|
+
def _collect(self) -> list[Row]:
|
|
89
|
+
batch: list[Row] = []
|
|
90
|
+
try:
|
|
91
|
+
batch.append(self.queue.get(timeout=self.flush_interval))
|
|
92
|
+
except queue.Empty:
|
|
93
|
+
return batch
|
|
94
|
+
while len(batch) < self.batch_size:
|
|
95
|
+
try:
|
|
96
|
+
batch.append(self.queue.get_nowait())
|
|
97
|
+
except queue.Empty:
|
|
98
|
+
break
|
|
99
|
+
return batch
|