slashpeak 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.
- slashpeak/__init__.py +53 -0
- slashpeak/__main__.py +5 -0
- slashpeak/_core/__init__.py +0 -0
- slashpeak/_core/mask.py +85 -0
- slashpeak/_core/router.py +379 -0
- slashpeak/_core/runtime.py +69 -0
- slashpeak/_core/settings.py +61 -0
- slashpeak/_core/transport.py +208 -0
- slashpeak/_version.py +1 -0
- slashpeak/bigquery/__init__.py +105 -0
- slashpeak/bigquery/hook.py +371 -0
- slashpeak/bigquery/requests_body.py +193 -0
- slashpeak/cli.py +25 -0
- slashpeak/doctor.py +191 -0
- slashpeak-0.1.0.dist-info/METADATA +121 -0
- slashpeak-0.1.0.dist-info/RECORD +19 -0
- slashpeak-0.1.0.dist-info/WHEEL +4 -0
- slashpeak-0.1.0.dist-info/entry_points.txt +2 -0
- slashpeak-0.1.0.dist-info/licenses/LICENSE +55 -0
slashpeak/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""SlashPeak Job Router: run each warehouse job where it costs less.
|
|
2
|
+
|
|
3
|
+
pip install "slashpeak[bigquery]"
|
|
4
|
+
|
|
5
|
+
import slashpeak
|
|
6
|
+
slashpeak.install() # hooks every supported library that is installed
|
|
7
|
+
|
|
8
|
+
Reads ``SLASHPEAK_API_KEY``, ``SLASHPEAK_MODE`` (off | shadow | active),
|
|
9
|
+
``SLASHPEAK_ENDPOINT`` and ``SLASHPEAK_SEND_PARAM_VALUES``. Without a key it
|
|
10
|
+
does nothing. On any error, or no answer within 500 ms, jobs run unchanged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from slashpeak._core.settings import Settings
|
|
19
|
+
from slashpeak._version import __version__
|
|
20
|
+
|
|
21
|
+
__all__ = ["__version__", "install", "uninstall", "Settings"]
|
|
22
|
+
|
|
23
|
+
#: Warehouse hook → the library it needs.
|
|
24
|
+
_HOOKS = {"bigquery": "google.cloud.bigquery"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _has(module: str) -> bool:
|
|
28
|
+
try:
|
|
29
|
+
return importlib.util.find_spec(module) is not None
|
|
30
|
+
except (ImportError, ValueError):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def install(settings: Optional[Settings] = None) -> List[str]:
|
|
35
|
+
"""Hooks every supported library that is installed. Returns the hooks that are on."""
|
|
36
|
+
on = []
|
|
37
|
+
for name, library in _HOOKS.items():
|
|
38
|
+
if not _has(library):
|
|
39
|
+
continue
|
|
40
|
+
hook = importlib.import_module(f"slashpeak.{name}")
|
|
41
|
+
if hook.install(settings):
|
|
42
|
+
on.append(name)
|
|
43
|
+
return on
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def uninstall() -> None:
|
|
47
|
+
"""Removes every hook and flushes pending events (at most 1 s)."""
|
|
48
|
+
import sys
|
|
49
|
+
|
|
50
|
+
for name in _HOOKS:
|
|
51
|
+
hook = sys.modules.get(f"slashpeak.{name}")
|
|
52
|
+
if hook is not None:
|
|
53
|
+
hook.uninstall()
|
slashpeak/__main__.py
ADDED
|
File without changes
|
slashpeak/_core/mask.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""PII masking before any SQL leaves the process.
|
|
2
|
+
|
|
3
|
+
Same rules, same order and same split as SlashPeak's scan (which masks inside
|
|
4
|
+
BigQuery with RE2): built-in email, phone and IP patterns, then the account's
|
|
5
|
+
own patterns from ``/v1/config``. Backtick-quoted names are left alone.
|
|
6
|
+
|
|
7
|
+
Python's ``re`` runs the patterns with ``re.ASCII``, because RE2's ``\\d``,
|
|
8
|
+
``\\b`` and ``\\s`` are ASCII-only. One known gap: Python's ASCII ``\\s`` also
|
|
9
|
+
matches a vertical tab, RE2's does not. The server masks again with RE2, so a
|
|
10
|
+
gap can only mask more, never send more.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
#: The router's built-in rules (``spec/mask_vectors.json`` → ``builtinRules``; a test keeps them equal).
|
|
19
|
+
BUILTIN_RULES: Tuple[Tuple[str, str], ...] = (
|
|
20
|
+
(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b", "<email>"),
|
|
21
|
+
(
|
|
22
|
+
r"\+\d(?:[-.\s()]{0,2}\d){6,14}\b|(?:\b1[-.\s])?(?:\(\d{3}\)[-.\s]?|\b\d{3}[-.\s])\d{3}[-.\s]\d{4}\b",
|
|
23
|
+
"<phone>",
|
|
24
|
+
),
|
|
25
|
+
(r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b", "<ip>"),
|
|
26
|
+
(
|
|
27
|
+
r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}(?::[0-9a-fA-F]{1,4}){1,6}\b"
|
|
28
|
+
r"|\b(?:[0-9a-fA-F]{1,4}:){1,7}:|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\b",
|
|
29
|
+
"<ip>",
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
CUSTOM_TOKEN = "<redacted>"
|
|
33
|
+
BUILTIN_REVISION = "builtin"
|
|
34
|
+
|
|
35
|
+
#: Same split as the scan's ``REGEXP_EXTRACT_ALL(text, r'`[^`]*`|[^`]+|`')``.
|
|
36
|
+
_SEGMENTS = re.compile(r"`[^`]*`|[^`]+|`")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MaskError(ValueError):
|
|
40
|
+
"""A rule does not compile. The caller then sends nothing."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Masker:
|
|
44
|
+
def __init__(self, rules: Iterable[Tuple[str, str]], revision: str) -> None:
|
|
45
|
+
compiled: List[Tuple["re.Pattern[str]", str]] = []
|
|
46
|
+
for pattern, token in rules:
|
|
47
|
+
try:
|
|
48
|
+
compiled.append((re.compile(pattern, re.ASCII), token))
|
|
49
|
+
except (re.error, TypeError, ValueError) as error:
|
|
50
|
+
raise MaskError("mask rule does not compile") from error
|
|
51
|
+
self._rules = tuple(compiled)
|
|
52
|
+
self.revision = revision
|
|
53
|
+
|
|
54
|
+
def _mask_segment(self, text: str) -> str:
|
|
55
|
+
for regex, token in self._rules:
|
|
56
|
+
# A function replacement: the token is used as written, never read for backrefs.
|
|
57
|
+
text = regex.sub(lambda _match, _token=token: _token, text)
|
|
58
|
+
return text
|
|
59
|
+
|
|
60
|
+
def mask(self, text: str) -> str:
|
|
61
|
+
out = []
|
|
62
|
+
for match in _SEGMENTS.finditer(text):
|
|
63
|
+
segment = match.group(0)
|
|
64
|
+
out.append(segment if len(segment) > 1 and segment.startswith("`") else self._mask_segment(segment))
|
|
65
|
+
return "".join(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def builtin_masker(owner_patterns: Sequence[str] = ()) -> Masker:
|
|
69
|
+
return Masker(list(BUILTIN_RULES) + [(p, CUSTOM_TOKEN) for p in owner_patterns], BUILTIN_REVISION)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def masker_from_config(mask: object) -> Optional[Masker]:
|
|
73
|
+
"""The masker for ``/v1/config``'s ``mask`` block; None when it is missing or malformed."""
|
|
74
|
+
if not isinstance(mask, dict):
|
|
75
|
+
return None
|
|
76
|
+
rules = mask.get("rules")
|
|
77
|
+
revision = mask.get("revision")
|
|
78
|
+
if not isinstance(rules, list) or not isinstance(revision, str):
|
|
79
|
+
return None
|
|
80
|
+
pairs = []
|
|
81
|
+
for rule in rules:
|
|
82
|
+
if not isinstance(rule, dict) or not isinstance(rule.get("pattern"), str) or not isinstance(rule.get("token"), str):
|
|
83
|
+
return None
|
|
84
|
+
pairs.append((rule["pattern"], rule["token"]))
|
|
85
|
+
return Masker(pairs, revision[:64])
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""The SDK's view of the router: decide (with breaker and cache), config poll, events.
|
|
2
|
+
|
|
3
|
+
Everything here fails open. Any doubt means "no decision", and the job runs as
|
|
4
|
+
the caller wrote it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import queue
|
|
15
|
+
import random
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from slashpeak._version import __version__
|
|
22
|
+
from slashpeak._core.mask import Masker, MaskError, builtin_masker, masker_from_config
|
|
23
|
+
from slashpeak._core.settings import Settings, stricter_mode
|
|
24
|
+
from slashpeak._core.transport import Reply, Transport
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("slashpeak")
|
|
27
|
+
|
|
28
|
+
BREAKER_FAILURES = 5
|
|
29
|
+
BREAKER_OPEN_S = 30.0
|
|
30
|
+
CACHE_MAX = 2048
|
|
31
|
+
EVENT_BATCH = 100
|
|
32
|
+
EVENT_INTERVAL_S = 2.0
|
|
33
|
+
EVENT_QUEUE_MAX = 5000
|
|
34
|
+
EXIT_FLUSH_S = 1.0
|
|
35
|
+
POLL_MIN_S = 10.0
|
|
36
|
+
POLL_MAX_S = 600.0
|
|
37
|
+
DEFAULT_TTL_S = 60.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _positive_number(value: Any) -> Optional[float]:
|
|
41
|
+
"""A finite number above 0, else None. JSON from Python's parser may carry NaN or Infinity."""
|
|
42
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
43
|
+
return None
|
|
44
|
+
number = float(value)
|
|
45
|
+
return number if math.isfinite(number) and number > 0 else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _version_tuple(value: str) -> Tuple[int, int, int]:
|
|
49
|
+
parts = []
|
|
50
|
+
for piece in value.replace("+", ".").replace("-", ".").split(".")[:3]:
|
|
51
|
+
digits = "".join(ch for ch in piece if ch.isdigit())
|
|
52
|
+
parts.append(int(digits) if digits else 0)
|
|
53
|
+
while len(parts) < 3:
|
|
54
|
+
parts.append(0)
|
|
55
|
+
return parts[0], parts[1], parts[2]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def client_at_least(version: str, minimum: Optional[str]) -> bool:
|
|
59
|
+
"""Same rule as the server's ``clientAtLeast``."""
|
|
60
|
+
return not minimum or _version_tuple(version) >= _version_tuple(minimum)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Breaker:
|
|
64
|
+
"""5 failures in a row → open 30 s → one probe. A good probe closes it."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
|
|
67
|
+
self._clock = clock
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
self._failures = 0
|
|
70
|
+
self._opened_at: Optional[float] = None
|
|
71
|
+
self._probing = False
|
|
72
|
+
|
|
73
|
+
def allow(self) -> bool:
|
|
74
|
+
with self._lock:
|
|
75
|
+
if self._opened_at is None:
|
|
76
|
+
return True
|
|
77
|
+
if self._probing or self._clock() - self._opened_at < BREAKER_OPEN_S:
|
|
78
|
+
return False
|
|
79
|
+
self._probing = True
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def record(self, ok: bool) -> None:
|
|
83
|
+
with self._lock:
|
|
84
|
+
if ok:
|
|
85
|
+
self._failures = 0
|
|
86
|
+
self._opened_at = None
|
|
87
|
+
else:
|
|
88
|
+
self._failures += 1
|
|
89
|
+
if self._probing or self._failures >= BREAKER_FAILURES:
|
|
90
|
+
self._opened_at = self._clock()
|
|
91
|
+
self._probing = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Flight:
|
|
95
|
+
__slots__ = ("done", "value")
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
self.done = threading.Event()
|
|
99
|
+
self.value: Optional[Dict[str, Any]] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DecisionCache:
|
|
103
|
+
"""Per-process cache of decisions, key = sha256(project, location, SQL). Single-flight per key."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
|
|
106
|
+
self._clock = clock
|
|
107
|
+
self._lock = threading.Lock()
|
|
108
|
+
self._entries: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
|
|
109
|
+
self._flights: Dict[str, _Flight] = {}
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def key(project: str, location: Optional[str], sql: str) -> str:
|
|
113
|
+
return hashlib.sha256("\x00".join((project, location or "", sql)).encode("utf-8")).hexdigest()
|
|
114
|
+
|
|
115
|
+
def get_or_fetch(self, key: str, fetch: Callable[[], Optional[Dict[str, Any]]], ttl_cap_s: float, wait_s: float) -> Optional[Dict[str, Any]]:
|
|
116
|
+
with self._lock:
|
|
117
|
+
hit = self._entries.get(key)
|
|
118
|
+
if hit is not None:
|
|
119
|
+
if hit[0] > self._clock():
|
|
120
|
+
self._entries.move_to_end(key)
|
|
121
|
+
return hit[1]
|
|
122
|
+
del self._entries[key]
|
|
123
|
+
flight = self._flights.get(key)
|
|
124
|
+
leader = flight is None
|
|
125
|
+
if leader:
|
|
126
|
+
flight = _Flight()
|
|
127
|
+
self._flights[key] = flight
|
|
128
|
+
assert flight is not None
|
|
129
|
+
if not leader:
|
|
130
|
+
# Another thread is asking for the same query: wait for its answer, within our deadline.
|
|
131
|
+
return flight.value if flight.done.wait(wait_s) else None
|
|
132
|
+
value: Optional[Dict[str, Any]] = None
|
|
133
|
+
try:
|
|
134
|
+
value = fetch()
|
|
135
|
+
finally:
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._flights.pop(key, None)
|
|
138
|
+
ttl = _cache_ttl(value, ttl_cap_s)
|
|
139
|
+
if value is not None and ttl > 0:
|
|
140
|
+
self._entries[key] = (self._clock() + ttl, value)
|
|
141
|
+
while len(self._entries) > CACHE_MAX:
|
|
142
|
+
self._entries.popitem(last=False)
|
|
143
|
+
flight.value = value
|
|
144
|
+
flight.done.set()
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
def clear(self) -> None:
|
|
148
|
+
with self._lock:
|
|
149
|
+
self._entries.clear()
|
|
150
|
+
self._flights.clear()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cache_ttl(decision: Optional[Dict[str, Any]], cap_s: float) -> float:
|
|
154
|
+
if not decision or decision.get("cacheScope") != "sql":
|
|
155
|
+
return 0.0
|
|
156
|
+
ttl = _positive_number(decision.get("ttlSeconds"))
|
|
157
|
+
return min(ttl, cap_s) if ttl is not None else 0.0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Router:
|
|
161
|
+
"""Process-wide router state. Thread-safe; ``after_fork_in_child`` rebuilds it in a child."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, settings: Settings, transport: Optional[Transport] = None, clock: Callable[[], float] = time.monotonic) -> None:
|
|
164
|
+
self.settings = settings
|
|
165
|
+
self._clock = clock
|
|
166
|
+
self.transport = transport or Transport(settings.endpoint, settings.api_key or "")
|
|
167
|
+
self.breaker = Breaker(clock)
|
|
168
|
+
self.cache = DecisionCache(clock)
|
|
169
|
+
self._config: Optional[Dict[str, Any]] = None
|
|
170
|
+
self._etag: Optional[str] = None
|
|
171
|
+
self._masker: Optional[Masker] = builtin_masker()
|
|
172
|
+
self._state_lock = threading.Lock()
|
|
173
|
+
self._events: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=EVENT_QUEUE_MAX)
|
|
174
|
+
self._stop = threading.Event()
|
|
175
|
+
self._threads_pid = -1
|
|
176
|
+
self._threads_lock = threading.Lock()
|
|
177
|
+
self._closed = False
|
|
178
|
+
|
|
179
|
+
# -- background threads ----------------------------------------------------------------
|
|
180
|
+
def start(self) -> None:
|
|
181
|
+
"""Starts the config and event threads once per process (again in a forked child)."""
|
|
182
|
+
if self._threads_pid == os.getpid() or self._closed:
|
|
183
|
+
return
|
|
184
|
+
with self._threads_lock:
|
|
185
|
+
if self._threads_pid == os.getpid():
|
|
186
|
+
return
|
|
187
|
+
self._threads_pid = os.getpid()
|
|
188
|
+
self._stop = threading.Event()
|
|
189
|
+
threading.Thread(target=self._config_loop, name="slashpeak-config", daemon=True).start()
|
|
190
|
+
threading.Thread(target=self._event_loop, name="slashpeak-events", daemon=True).start()
|
|
191
|
+
|
|
192
|
+
def after_fork_in_child(self) -> None:
|
|
193
|
+
# Locks held by another thread at fork time stay locked forever in the child: make new ones.
|
|
194
|
+
self._state_lock = threading.Lock()
|
|
195
|
+
self._threads_lock = threading.Lock()
|
|
196
|
+
self.breaker = Breaker(self._clock)
|
|
197
|
+
self.cache = DecisionCache(self._clock)
|
|
198
|
+
self._events = queue.Queue(maxsize=EVENT_QUEUE_MAX)
|
|
199
|
+
self._threads_pid = -1
|
|
200
|
+
self.transport.after_fork_in_child()
|
|
201
|
+
|
|
202
|
+
def close(self, flush_s: float = EXIT_FLUSH_S) -> None:
|
|
203
|
+
if self._closed:
|
|
204
|
+
return
|
|
205
|
+
self._closed = True
|
|
206
|
+
self._stop.set()
|
|
207
|
+
if self._threads_pid == os.getpid():
|
|
208
|
+
self.flush_events(deadline_s=flush_s)
|
|
209
|
+
self.transport.close()
|
|
210
|
+
|
|
211
|
+
# -- config ----------------------------------------------------------------------------
|
|
212
|
+
def poll_config(self) -> bool:
|
|
213
|
+
"""One ``GET /v1/config`` with ETag. True when the call worked (200 or 304)."""
|
|
214
|
+
headers = {"If-None-Match": self._etag} if self._etag else None
|
|
215
|
+
try:
|
|
216
|
+
reply = self.transport.request("GET", "/v1/config", headers=headers)
|
|
217
|
+
except Exception: # noqa: BLE001
|
|
218
|
+
logger.debug("slashpeak config fetch failed", extra={"code": "network"})
|
|
219
|
+
return False
|
|
220
|
+
if reply.status == 304:
|
|
221
|
+
return True
|
|
222
|
+
if reply.status != 200 or reply.body is None:
|
|
223
|
+
logger.debug("slashpeak config fetch failed", extra={"code": f"http_{reply.status}"})
|
|
224
|
+
return False
|
|
225
|
+
self._apply_config(reply)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
def _apply_config(self, reply: Reply) -> None:
|
|
229
|
+
config = reply.body or {}
|
|
230
|
+
masker: Optional[Masker]
|
|
231
|
+
try:
|
|
232
|
+
masker = masker_from_config(config.get("mask"))
|
|
233
|
+
except MaskError:
|
|
234
|
+
masker = None # A rule Python cannot compile: send nothing until it changes.
|
|
235
|
+
else:
|
|
236
|
+
if masker is None:
|
|
237
|
+
masker = builtin_masker()
|
|
238
|
+
with self._state_lock:
|
|
239
|
+
self._config = config
|
|
240
|
+
self._etag = reply.etag
|
|
241
|
+
self._masker = masker
|
|
242
|
+
|
|
243
|
+
def _poll_seconds(self) -> float:
|
|
244
|
+
config = self._config or {}
|
|
245
|
+
value = config.get("pollSeconds")
|
|
246
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value):
|
|
247
|
+
return max(POLL_MIN_S, min(POLL_MAX_S, float(value)))
|
|
248
|
+
return 60.0
|
|
249
|
+
|
|
250
|
+
def _config_loop(self) -> None:
|
|
251
|
+
while not self._stop.is_set():
|
|
252
|
+
ok = self.poll_config()
|
|
253
|
+
wait = self._poll_seconds() if ok else POLL_MIN_S
|
|
254
|
+
# Jitter, so a fleet of workers started together does not poll together.
|
|
255
|
+
self._stop.wait(wait * random.uniform(0.9, 1.1))
|
|
256
|
+
|
|
257
|
+
def masker(self) -> Optional[Masker]:
|
|
258
|
+
return self._masker
|
|
259
|
+
|
|
260
|
+
def config(self) -> Optional[Dict[str, Any]]:
|
|
261
|
+
return self._config
|
|
262
|
+
|
|
263
|
+
# -- gating ----------------------------------------------------------------------------
|
|
264
|
+
def _policies_for(self, project: str) -> Optional[List[Dict[str, Any]]]:
|
|
265
|
+
"""Policies that may cover ``project``; None when no config has arrived yet."""
|
|
266
|
+
config = self._config
|
|
267
|
+
if config is None:
|
|
268
|
+
return None
|
|
269
|
+
policies = [p for p in config.get("policies") or [] if isinstance(p, dict)]
|
|
270
|
+
out = []
|
|
271
|
+
for policy in policies:
|
|
272
|
+
projects = policy.get("projects") or []
|
|
273
|
+
# An empty list covers every project of that connection (the server's rule).
|
|
274
|
+
if not projects or project in projects:
|
|
275
|
+
out.append(policy)
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
def may_decide(self, project: str) -> bool:
|
|
279
|
+
if self._closed or not self.settings.usable:
|
|
280
|
+
return False
|
|
281
|
+
config = self._config
|
|
282
|
+
if config is None:
|
|
283
|
+
return True # First calls before the config arrives: the server still applies every rule.
|
|
284
|
+
if config.get("killSwitch") is True:
|
|
285
|
+
return False
|
|
286
|
+
minimum = (config.get("minClient") or {}).get("python") if isinstance(config.get("minClient"), dict) else None
|
|
287
|
+
if not client_at_least(__version__, minimum):
|
|
288
|
+
return False
|
|
289
|
+
policies = self._policies_for(project) or []
|
|
290
|
+
return any(stricter_mode(self.settings.mode, p.get("mode")) != "off" for p in policies)
|
|
291
|
+
|
|
292
|
+
def send_param_values(self, project: str) -> bool:
|
|
293
|
+
if not self.settings.send_param_values:
|
|
294
|
+
return False
|
|
295
|
+
policies = self._policies_for(project)
|
|
296
|
+
# Off if any policy that may cover the project says off.
|
|
297
|
+
return all(p.get("sendParamValues") is not False for p in policies or [])
|
|
298
|
+
|
|
299
|
+
def ttl_cap_s(self) -> float:
|
|
300
|
+
value = _positive_number((self._config or {}).get("decisionTtlSeconds"))
|
|
301
|
+
return value if value is not None else DEFAULT_TTL_S
|
|
302
|
+
|
|
303
|
+
# -- decide ----------------------------------------------------------------------------
|
|
304
|
+
def decide(self, cache_key: str, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
305
|
+
self.start()
|
|
306
|
+
return self.cache.get_or_fetch(cache_key, lambda: self._decide_call(request), self.ttl_cap_s(), wait_s=0.5)
|
|
307
|
+
|
|
308
|
+
def _decide_call(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
309
|
+
if not self.breaker.allow():
|
|
310
|
+
return None
|
|
311
|
+
reply = self.transport.request_with_deadline("POST", "/v1/decide", request)
|
|
312
|
+
ok = reply is not None and reply.status == 200 and isinstance(reply.body, dict)
|
|
313
|
+
self.breaker.record(ok)
|
|
314
|
+
if not ok:
|
|
315
|
+
logger.debug("slashpeak decide skipped", extra={"code": "timeout" if reply is None else f"http_{reply.status}"})
|
|
316
|
+
return None
|
|
317
|
+
return reply.body
|
|
318
|
+
|
|
319
|
+
# -- events ----------------------------------------------------------------------------
|
|
320
|
+
def emit(self, event: Dict[str, Any]) -> None:
|
|
321
|
+
if not event.get("decisionId") or self._closed:
|
|
322
|
+
return
|
|
323
|
+
self.start()
|
|
324
|
+
try:
|
|
325
|
+
self._events.put_nowait(event)
|
|
326
|
+
except queue.Full:
|
|
327
|
+
pass # Bounded: events are best effort and never slow a job.
|
|
328
|
+
|
|
329
|
+
def _drain(self, limit: int) -> List[Dict[str, Any]]:
|
|
330
|
+
batch: List[Dict[str, Any]] = []
|
|
331
|
+
while len(batch) < limit:
|
|
332
|
+
try:
|
|
333
|
+
batch.append(self._events.get_nowait())
|
|
334
|
+
except queue.Empty:
|
|
335
|
+
break
|
|
336
|
+
return batch
|
|
337
|
+
|
|
338
|
+
def _send_events(self, batch: List[Dict[str, Any]], timeout: Tuple[float, float]) -> None:
|
|
339
|
+
if not batch:
|
|
340
|
+
return
|
|
341
|
+
try:
|
|
342
|
+
self.transport.request("POST", "/v1/events", {"events": batch}, timeout=timeout)
|
|
343
|
+
except Exception: # noqa: BLE001
|
|
344
|
+
logger.debug("slashpeak events dropped", extra={"code": "network", "count": len(batch)})
|
|
345
|
+
|
|
346
|
+
def _event_loop(self) -> None:
|
|
347
|
+
while not self._stop.wait(EVENT_INTERVAL_S):
|
|
348
|
+
while True:
|
|
349
|
+
batch = self._drain(EVENT_BATCH)
|
|
350
|
+
self._send_events(batch, (2.0, 5.0))
|
|
351
|
+
if len(batch) < EVENT_BATCH:
|
|
352
|
+
break
|
|
353
|
+
|
|
354
|
+
def flush_events(self, deadline_s: float = EXIT_FLUSH_S) -> None:
|
|
355
|
+
end = time.monotonic() + deadline_s
|
|
356
|
+
while time.monotonic() < end:
|
|
357
|
+
batch = self._drain(EVENT_BATCH)
|
|
358
|
+
if not batch:
|
|
359
|
+
return
|
|
360
|
+
left = max(0.05, end - time.monotonic())
|
|
361
|
+
self._send_events(batch, (min(0.5, left), left))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
_EXIT_HOOKED = False
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def register_exit(router_getter: Callable[[], Optional[Router]]) -> None:
|
|
368
|
+
"""Flush events for at most 1 s at interpreter exit."""
|
|
369
|
+
global _EXIT_HOOKED
|
|
370
|
+
if _EXIT_HOOKED:
|
|
371
|
+
return
|
|
372
|
+
_EXIT_HOOKED = True
|
|
373
|
+
|
|
374
|
+
def _on_exit() -> None:
|
|
375
|
+
router = router_getter()
|
|
376
|
+
if router is not None:
|
|
377
|
+
router.close()
|
|
378
|
+
|
|
379
|
+
atexit.register(_on_exit)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The one router per process, shared by every warehouse hook.
|
|
2
|
+
|
|
3
|
+
A process that uses BigQuery and Snowflake keeps one key, one connection pool,
|
|
4
|
+
one config poll and one event queue.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Callable, List, Optional, Set
|
|
12
|
+
|
|
13
|
+
from slashpeak._core.router import Router, register_exit
|
|
14
|
+
from slashpeak._core.settings import Settings, load_settings
|
|
15
|
+
|
|
16
|
+
_lock = threading.Lock()
|
|
17
|
+
_router: Optional[Router] = None
|
|
18
|
+
_users: Set[str] = set()
|
|
19
|
+
_fork_callbacks: List[Callable[[], None]] = []
|
|
20
|
+
_fork_hooked = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def current() -> Optional[Router]:
|
|
24
|
+
return _router
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _after_fork_in_child() -> None:
|
|
28
|
+
global _lock
|
|
29
|
+
# A lock held by another thread at fork time stays locked in the child: rebuild every one.
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
for callback in list(_fork_callbacks):
|
|
32
|
+
callback()
|
|
33
|
+
router = _router
|
|
34
|
+
if router is not None:
|
|
35
|
+
router.after_fork_in_child()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def on_fork(callback: Callable[[], None]) -> None:
|
|
39
|
+
"""Runs ``callback`` in a forked child, before the router resets."""
|
|
40
|
+
if callback not in _fork_callbacks:
|
|
41
|
+
_fork_callbacks.append(callback)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def acquire(user: str, settings: Optional[Settings] = None) -> Router:
|
|
45
|
+
"""The process router for one hook (``bigquery``, …). New settings replace the router."""
|
|
46
|
+
global _router, _fork_hooked
|
|
47
|
+
with _lock:
|
|
48
|
+
# Same settings from a second hook keep the router (one pool, one poll, one queue).
|
|
49
|
+
if _router is None or (settings is not None and settings != _router.settings):
|
|
50
|
+
if _router is not None:
|
|
51
|
+
_router.close(flush_s=0.0)
|
|
52
|
+
_router = Router(settings or load_settings())
|
|
53
|
+
register_exit(current)
|
|
54
|
+
if not _fork_hooked and hasattr(os, "register_at_fork"):
|
|
55
|
+
os.register_at_fork(after_in_child=_after_fork_in_child)
|
|
56
|
+
_fork_hooked = True
|
|
57
|
+
_users.add(user)
|
|
58
|
+
return _router
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def release(user: str) -> None:
|
|
62
|
+
"""Closes the router (flushing events, at most 1 s) when its last hook goes."""
|
|
63
|
+
global _router
|
|
64
|
+
with _lock:
|
|
65
|
+
_users.discard(user)
|
|
66
|
+
if _users or _router is None:
|
|
67
|
+
return
|
|
68
|
+
router, _router = _router, None
|
|
69
|
+
router.close()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""SDK settings, read once from the environment (or passed to ``install``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Mapping, Optional
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
MODES = ("off", "shadow", "active")
|
|
11
|
+
DEFAULT_ENDPOINT = "https://router.slashpeak.com"
|
|
12
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Settings:
|
|
17
|
+
api_key: Optional[str]
|
|
18
|
+
endpoint: str = DEFAULT_ENDPOINT
|
|
19
|
+
#: The SDK's own ceiling. The stricter of this and the server's mode wins.
|
|
20
|
+
mode: str = "active"
|
|
21
|
+
#: Send query parameter values (masked first). Off if the SDK or the server says off.
|
|
22
|
+
send_param_values: bool = True
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def usable(self) -> bool:
|
|
26
|
+
"""A key, a mode other than off, and an endpoint the key may be sent to."""
|
|
27
|
+
return bool(self.api_key) and self.mode != "off" and endpoint_is_safe(self.endpoint)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def endpoint_is_safe(endpoint: str) -> bool:
|
|
31
|
+
"""HTTPS only. Plain HTTP is allowed for a local test server, never for a remote host."""
|
|
32
|
+
try:
|
|
33
|
+
parts = urlsplit(endpoint)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False
|
|
36
|
+
if parts.scheme == "https" and parts.hostname:
|
|
37
|
+
return True
|
|
38
|
+
return parts.scheme == "http" and (parts.hostname or "") in _LOCAL_HOSTS
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _flag(value: Optional[str], default: bool) -> bool:
|
|
42
|
+
if value is None or value.strip() == "":
|
|
43
|
+
return default
|
|
44
|
+
return value.strip().lower() not in ("0", "false", "off", "no")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_settings(env: Mapping[str, str] = os.environ) -> Settings:
|
|
48
|
+
mode = (env.get("SLASHPEAK_MODE") or "active").strip().lower()
|
|
49
|
+
return Settings(
|
|
50
|
+
api_key=(env.get("SLASHPEAK_API_KEY") or "").strip() or None,
|
|
51
|
+
endpoint=(env.get("SLASHPEAK_ENDPOINT") or DEFAULT_ENDPOINT).strip().rstrip("/"),
|
|
52
|
+
# An unknown mode is a typo: do nothing rather than guess.
|
|
53
|
+
mode=mode if mode in MODES else "off",
|
|
54
|
+
send_param_values=_flag(env.get("SLASHPEAK_SEND_PARAM_VALUES"), True),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def stricter_mode(a: Optional[str], b: Optional[str]) -> str:
|
|
59
|
+
"""off < shadow < active; an unknown value counts as off."""
|
|
60
|
+
rank = {mode: i for i, mode in enumerate(MODES)}
|
|
61
|
+
return MODES[min(rank.get(a or "off", 0), rank.get(b or "off", 0))]
|