richrelay 1.0.2__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.
- richrelay/__init__.py +33 -0
- richrelay/__main__.py +5 -0
- richrelay/_version.py +1 -0
- richrelay/buffers/__init__.py +10 -0
- richrelay/buffers/base.py +45 -0
- richrelay/buffers/memory.py +62 -0
- richrelay/buffers/rabbitmq_buffer.py +135 -0
- richrelay/buffers/redis_buffer.py +138 -0
- richrelay/buffers/sqlite.py +126 -0
- richrelay/channel.py +351 -0
- richrelay/cli.py +65 -0
- richrelay/event.py +26 -0
- richrelay/formats.py +231 -0
- richrelay/integrations.py +259 -0
- richrelay/listener.py +205 -0
- richrelay/parsers.py +97 -0
- richrelay/py.typed +0 -0
- richrelay/relay.py +215 -0
- richrelay/syslog.py +83 -0
- richrelay/transports.py +253 -0
- richrelay/urls.py +83 -0
- richrelay-1.0.2.dist-info/METADATA +377 -0
- richrelay-1.0.2.dist-info/RECORD +26 -0
- richrelay-1.0.2.dist-info/WHEEL +4 -0
- richrelay-1.0.2.dist-info/entry_points.txt +2 -0
- richrelay-1.0.2.dist-info/licenses/LICENSE +21 -0
richrelay/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""richrelay - ship ``rich`` output, logging and exceptions to syslog and friends."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .buffers import (
|
|
5
|
+
Buffer,
|
|
6
|
+
MemoryBuffer,
|
|
7
|
+
RabbitMQBuffer,
|
|
8
|
+
RedisBuffer,
|
|
9
|
+
RedisCache,
|
|
10
|
+
SpoolError,
|
|
11
|
+
SQLiteBuffer,
|
|
12
|
+
)
|
|
13
|
+
from .event import Event
|
|
14
|
+
from .formats import FORMATS, Formatter, make_formatter
|
|
15
|
+
from .integrations import (
|
|
16
|
+
RelayHandler,
|
|
17
|
+
RichRelayHandler,
|
|
18
|
+
event_from_record,
|
|
19
|
+
install_excepthook,
|
|
20
|
+
patch_rich,
|
|
21
|
+
)
|
|
22
|
+
from .relay import Destination, Relay, get_relay, setup, shutdown, tcp, tls, udp, unix
|
|
23
|
+
from .transports import TCPTransport, Transport, TransportError, UDPTransport, UnixTransport
|
|
24
|
+
from .urls import parse_buffer, parse_destination
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__", "Event", "Relay", "Destination", "setup", "shutdown", "get_relay",
|
|
28
|
+
"udp", "tcp", "tls", "unix", "parse_destination", "parse_buffer",
|
|
29
|
+
"RelayHandler", "RichRelayHandler", "patch_rich", "install_excepthook", "event_from_record",
|
|
30
|
+
"Buffer", "MemoryBuffer", "SQLiteBuffer", "RedisBuffer", "RedisCache", "RabbitMQBuffer",
|
|
31
|
+
"SpoolError", "Formatter", "FORMATS", "make_formatter",
|
|
32
|
+
"Transport", "TransportError", "UDPTransport", "TCPTransport", "UnixTransport",
|
|
33
|
+
] # fmt: skip
|
richrelay/__main__.py
ADDED
richrelay/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.4"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .base import Buffer, SpoolError
|
|
2
|
+
from .memory import MemoryBuffer
|
|
3
|
+
from .rabbitmq_buffer import RabbitMQBuffer
|
|
4
|
+
from .redis_buffer import RedisBuffer, RedisCache
|
|
5
|
+
from .sqlite import SQLiteBuffer
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Buffer", "SpoolError", "MemoryBuffer", "SQLiteBuffer",
|
|
9
|
+
"RedisBuffer", "RedisCache", "RabbitMQBuffer",
|
|
10
|
+
] # fmt: skip
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Buffer interface used for store-and-forward while a receiver is down."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpoolError(Exception):
|
|
9
|
+
"""The buffer backend itself is unavailable (Redis down, broker down, disk error...)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Buffer:
|
|
13
|
+
"""FIFO of already-formatted payloads.
|
|
14
|
+
|
|
15
|
+
Contract (single consumer, used from one worker thread):
|
|
16
|
+
``peek(n)`` returns up to ``n`` oldest payloads without removing them;
|
|
17
|
+
``discard(n)`` removes the first ``n`` of the last peek.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
overflow = "drop_oldest"
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.dropped = 0
|
|
24
|
+
self.known_len = 0 # cheap, possibly approximate size for stats
|
|
25
|
+
|
|
26
|
+
def push(self, payload: bytes) -> bool: # pragma: no cover - interface
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def peek(self, n: int) -> List[bytes]: # pragma: no cover - interface
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def discard(self, n: int) -> None: # pragma: no cover - interface
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def __len__(self) -> int: # pragma: no cover - interface
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_overflow(value: str) -> str:
|
|
43
|
+
if value not in ("drop_oldest", "drop_newest"):
|
|
44
|
+
raise ValueError("overflow must be 'drop_oldest' or 'drop_newest'")
|
|
45
|
+
return value
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections import deque
|
|
5
|
+
from typing import Deque, List, Optional
|
|
6
|
+
|
|
7
|
+
from .base import Buffer, check_overflow
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MemoryBuffer(Buffer):
|
|
11
|
+
"""Bounded in-process buffer. Lost when the process exits."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, max_items: int = 10_000, max_bytes: Optional[int] = None,
|
|
14
|
+
overflow: str = "drop_oldest") -> None:
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.max_items, self.max_bytes = max_items, max_bytes
|
|
17
|
+
self.overflow = check_overflow(overflow)
|
|
18
|
+
self._d: Deque[bytes] = deque()
|
|
19
|
+
self._bytes = 0
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
def _over(self) -> bool:
|
|
23
|
+
return len(self._d) > self.max_items or (
|
|
24
|
+
self.max_bytes is not None and self._bytes > self.max_bytes
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def push(self, payload: bytes) -> bool:
|
|
28
|
+
with self._lock:
|
|
29
|
+
if self.max_bytes is not None and len(payload) > self.max_bytes:
|
|
30
|
+
self.dropped += 1
|
|
31
|
+
return False
|
|
32
|
+
if self.overflow == "drop_newest":
|
|
33
|
+
if len(self._d) >= self.max_items or (
|
|
34
|
+
self.max_bytes is not None and self._bytes + len(payload) > self.max_bytes
|
|
35
|
+
):
|
|
36
|
+
self.dropped += 1
|
|
37
|
+
return False
|
|
38
|
+
self._d.append(payload)
|
|
39
|
+
self._bytes += len(payload)
|
|
40
|
+
while self._over() and self._d:
|
|
41
|
+
self._bytes -= len(self._d.popleft())
|
|
42
|
+
self.dropped += 1
|
|
43
|
+
self.known_len = len(self._d)
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def peek(self, n: int) -> List[bytes]:
|
|
47
|
+
with self._lock:
|
|
48
|
+
out = []
|
|
49
|
+
for i, item in enumerate(self._d):
|
|
50
|
+
if i >= n:
|
|
51
|
+
break
|
|
52
|
+
out.append(item)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
def discard(self, n: int) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
for _ in range(min(n, len(self._d))):
|
|
58
|
+
self._bytes -= len(self._d.popleft())
|
|
59
|
+
self.known_len = len(self._d)
|
|
60
|
+
|
|
61
|
+
def __len__(self) -> int:
|
|
62
|
+
return len(self._d)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from .base import Buffer, SpoolError, check_overflow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RabbitMQBuffer(Buffer):
|
|
9
|
+
"""Durable store-and-forward queue in RabbitMQ (pika BlockingConnection).
|
|
10
|
+
|
|
11
|
+
The queue is declared durable with ``x-max-length``; messages are published persistent
|
|
12
|
+
and only acknowledged once the real receiver has taken them. If the process dies
|
|
13
|
+
mid-replay, unacknowledged messages are redelivered (at-least-once).
|
|
14
|
+
|
|
15
|
+
Note: with ``drop_oldest`` the *broker* discards the head, so ``dropped`` is not tracked.
|
|
16
|
+
Changing ``max_items``/``overflow`` for an existing queue requires deleting the queue
|
|
17
|
+
(RabbitMQ rejects redeclaration with different arguments).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, url: str = "amqp://guest:guest@localhost:5672/%2F", *,
|
|
21
|
+
queue: str = "richrelay.spool", max_items: int = 100_000,
|
|
22
|
+
overflow: str = "drop_oldest", durable: bool = True) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.url, self.queue, self.max_items, self.durable = url, queue, max_items, durable
|
|
25
|
+
self.overflow = check_overflow(overflow)
|
|
26
|
+
self._conn: Any = None
|
|
27
|
+
self._ch: Any = None
|
|
28
|
+
self._pending: List[Tuple[int, bytes]] = [] # (delivery_tag, body)
|
|
29
|
+
|
|
30
|
+
def _connect(self) -> None:
|
|
31
|
+
try:
|
|
32
|
+
import pika
|
|
33
|
+
except ImportError as exc: # pragma: no cover
|
|
34
|
+
raise ImportError("RabbitMQBuffer needs: pip install 'richrelay[rabbitmq]'") from exc
|
|
35
|
+
params = pika.URLParameters(self.url)
|
|
36
|
+
params.socket_timeout = 5
|
|
37
|
+
params.blocked_connection_timeout = 10
|
|
38
|
+
self._conn = pika.BlockingConnection(params)
|
|
39
|
+
self._ch = self._conn.channel()
|
|
40
|
+
self._ch.queue_declare(
|
|
41
|
+
queue=self.queue, durable=self.durable,
|
|
42
|
+
arguments={
|
|
43
|
+
"x-max-length": self.max_items,
|
|
44
|
+
"x-overflow": "drop-head" if self.overflow == "drop_oldest" else "reject-publish",
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
self._ch.confirm_delivery()
|
|
48
|
+
|
|
49
|
+
def _reset(self) -> None:
|
|
50
|
+
self._pending = [] # unacked messages return to the queue when the connection closes
|
|
51
|
+
try:
|
|
52
|
+
if self._conn is not None and self._conn.is_open:
|
|
53
|
+
self._conn.close()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
self._conn = self._ch = None
|
|
57
|
+
|
|
58
|
+
def _call(self, fn: Any) -> Any:
|
|
59
|
+
last: Optional[BaseException] = None
|
|
60
|
+
for _ in range(2): # one transparent reconnect (idle connections get closed)
|
|
61
|
+
try:
|
|
62
|
+
if self._conn is None or self._conn.is_closed:
|
|
63
|
+
self._pending = []
|
|
64
|
+
self._connect()
|
|
65
|
+
return fn(self._ch)
|
|
66
|
+
except SpoolError:
|
|
67
|
+
raise
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
last = exc
|
|
70
|
+
self._reset()
|
|
71
|
+
raise SpoolError(f"rabbitmq {self.queue}: {last}") from last
|
|
72
|
+
|
|
73
|
+
def push(self, payload: bytes) -> bool:
|
|
74
|
+
import pika
|
|
75
|
+
|
|
76
|
+
def op(ch: Any) -> bool:
|
|
77
|
+
try:
|
|
78
|
+
ch.basic_publish(
|
|
79
|
+
exchange="", routing_key=self.queue, body=payload,
|
|
80
|
+
properties=pika.BasicProperties(delivery_mode=2 if self.durable else 1),
|
|
81
|
+
mandatory=False,
|
|
82
|
+
)
|
|
83
|
+
return True
|
|
84
|
+
except (pika.exceptions.NackError, pika.exceptions.UnroutableError):
|
|
85
|
+
self.dropped += 1 # reject-publish: queue full
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
ok = self._call(op)
|
|
89
|
+
if ok:
|
|
90
|
+
self.known_len += 1
|
|
91
|
+
return bool(ok)
|
|
92
|
+
|
|
93
|
+
def peek(self, n: int) -> List[bytes]:
|
|
94
|
+
def op(ch: Any) -> List[bytes]:
|
|
95
|
+
while len(self._pending) < n:
|
|
96
|
+
method, _props, body = ch.basic_get(queue=self.queue, auto_ack=False)
|
|
97
|
+
if method is None:
|
|
98
|
+
break
|
|
99
|
+
self._pending.append((method.delivery_tag, bytes(body)))
|
|
100
|
+
return [b for _t, b in self._pending[:n]]
|
|
101
|
+
|
|
102
|
+
return self._call(op)
|
|
103
|
+
|
|
104
|
+
def discard(self, n: int) -> None:
|
|
105
|
+
if n <= 0 or not self._pending:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
def op(ch: Any) -> None:
|
|
109
|
+
k = min(n, len(self._pending))
|
|
110
|
+
ch.basic_ack(delivery_tag=self._pending[k - 1][0], multiple=True)
|
|
111
|
+
self._pending = self._pending[k:]
|
|
112
|
+
self.known_len = max(0, self.known_len - k)
|
|
113
|
+
|
|
114
|
+
# no retry-by-reconnect here: a reconnect invalidates delivery tags
|
|
115
|
+
try:
|
|
116
|
+
if self._ch is None:
|
|
117
|
+
return
|
|
118
|
+
op(self._ch)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
self._reset()
|
|
121
|
+
raise SpoolError(f"rabbitmq {self.queue}: ack failed ({exc}); messages will be redelivered") from exc
|
|
122
|
+
|
|
123
|
+
def __len__(self) -> int:
|
|
124
|
+
def op(ch: Any) -> int:
|
|
125
|
+
return int(ch.queue_declare(queue=self.queue, passive=True).method.message_count)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
ready = self._call(op)
|
|
129
|
+
except SpoolError:
|
|
130
|
+
raise
|
|
131
|
+
self.known_len = ready + len(self._pending)
|
|
132
|
+
return self.known_len
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
self._reset()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
from .base import Buffer, SpoolError, check_overflow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _import_redis() -> Any:
|
|
9
|
+
try:
|
|
10
|
+
import redis
|
|
11
|
+
except ImportError as exc: # pragma: no cover
|
|
12
|
+
raise ImportError("RedisBuffer needs the extra: pip install 'richrelay[redis]'") from exc
|
|
13
|
+
return redis
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedisBuffer(Buffer):
|
|
17
|
+
"""Store-and-forward queue backed by a Redis list (RPUSH / LRANGE / LTRIM).
|
|
18
|
+
|
|
19
|
+
Bounded by ``max_items`` only (Redis lists have no byte limit).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, url: str = "redis://localhost:6379/0", *, key: str = "richrelay:spool",
|
|
23
|
+
max_items: int = 100_000, overflow: str = "drop_oldest",
|
|
24
|
+
client: Any = None, socket_timeout: float = 3.0) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.url, self.key, self.max_items = url, key, max_items
|
|
27
|
+
self.overflow = check_overflow(overflow)
|
|
28
|
+
self.socket_timeout = socket_timeout
|
|
29
|
+
self._client = client
|
|
30
|
+
self._owns_client = client is None # never close a client the caller injected
|
|
31
|
+
|
|
32
|
+
def _c(self) -> Any:
|
|
33
|
+
if self._client is None:
|
|
34
|
+
redis = _import_redis()
|
|
35
|
+
self._client = redis.Redis.from_url(
|
|
36
|
+
self.url, socket_timeout=self.socket_timeout,
|
|
37
|
+
socket_connect_timeout=self.socket_timeout,
|
|
38
|
+
)
|
|
39
|
+
return self._client
|
|
40
|
+
|
|
41
|
+
def _call(self, fn: Any) -> Any:
|
|
42
|
+
try:
|
|
43
|
+
return fn(self._c())
|
|
44
|
+
except SpoolError:
|
|
45
|
+
raise
|
|
46
|
+
except Exception as exc: # redis.RedisError, OSError
|
|
47
|
+
raise SpoolError(f"redis {self.key}: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
def push(self, payload: bytes) -> bool:
|
|
50
|
+
if self.overflow == "drop_newest":
|
|
51
|
+
if self._call(lambda c: c.llen(self.key)) >= self.max_items:
|
|
52
|
+
self.dropped += 1
|
|
53
|
+
return False
|
|
54
|
+
self.known_len = self._call(lambda c: c.rpush(self.key, payload))
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def op(c: Any) -> int:
|
|
58
|
+
pipe = c.pipeline(transaction=True)
|
|
59
|
+
pipe.rpush(self.key, payload)
|
|
60
|
+
pipe.ltrim(self.key, -self.max_items, -1)
|
|
61
|
+
return int(pipe.execute()[0])
|
|
62
|
+
|
|
63
|
+
new_len = self._call(op)
|
|
64
|
+
if new_len > self.max_items:
|
|
65
|
+
self.dropped += new_len - self.max_items
|
|
66
|
+
new_len = self.max_items
|
|
67
|
+
self.known_len = new_len
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
def peek(self, n: int) -> List[bytes]:
|
|
71
|
+
return [bytes(x) for x in self._call(lambda c: c.lrange(self.key, 0, n - 1))]
|
|
72
|
+
|
|
73
|
+
def discard(self, n: int) -> None:
|
|
74
|
+
if n > 0:
|
|
75
|
+
self._call(lambda c: c.ltrim(self.key, n, -1))
|
|
76
|
+
self.known_len = max(0, self.known_len - n)
|
|
77
|
+
|
|
78
|
+
def __len__(self) -> int:
|
|
79
|
+
self.known_len = int(self._call(lambda c: c.llen(self.key)))
|
|
80
|
+
return self.known_len
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
if not self._owns_client:
|
|
84
|
+
return
|
|
85
|
+
c, self._client = self._client, None
|
|
86
|
+
try:
|
|
87
|
+
if c is not None:
|
|
88
|
+
c.close()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RedisCache:
|
|
94
|
+
"""Rolling cache of the most recent events (always written, delivery-independent).
|
|
95
|
+
|
|
96
|
+
Keeps the newest ``max_items`` payloads in a list, optionally expiring after ``ttl`` seconds.
|
|
97
|
+
Read them back with :meth:`recent`.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, url: str = "redis://localhost:6379/0", *, key: str = "richrelay:recent",
|
|
101
|
+
max_items: int = 1000, ttl: Optional[int] = None, format: str = "json",
|
|
102
|
+
client: Any = None, socket_timeout: float = 3.0) -> None:
|
|
103
|
+
self.url, self.key, self.max_items, self.ttl = url, key, max_items, ttl
|
|
104
|
+
self.format = format
|
|
105
|
+
self.socket_timeout = socket_timeout
|
|
106
|
+
self._client = client
|
|
107
|
+
self._owns_client = client is None
|
|
108
|
+
|
|
109
|
+
def _c(self) -> Any:
|
|
110
|
+
if self._client is None:
|
|
111
|
+
redis = _import_redis()
|
|
112
|
+
self._client = redis.Redis.from_url(
|
|
113
|
+
self.url, socket_timeout=self.socket_timeout,
|
|
114
|
+
socket_connect_timeout=self.socket_timeout,
|
|
115
|
+
)
|
|
116
|
+
return self._client
|
|
117
|
+
|
|
118
|
+
def put(self, payload: bytes) -> None:
|
|
119
|
+
pipe = self._c().pipeline(transaction=True)
|
|
120
|
+
pipe.lpush(self.key, payload)
|
|
121
|
+
pipe.ltrim(self.key, 0, self.max_items - 1)
|
|
122
|
+
if self.ttl:
|
|
123
|
+
pipe.expire(self.key, int(self.ttl))
|
|
124
|
+
pipe.execute()
|
|
125
|
+
|
|
126
|
+
def recent(self, n: int = 100) -> List[str]:
|
|
127
|
+
"""Newest first."""
|
|
128
|
+
return [bytes(x).decode("utf-8", "replace") for x in self._c().lrange(self.key, 0, n - 1)]
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
if not self._owns_client:
|
|
132
|
+
return
|
|
133
|
+
c, self._client = self._client, None
|
|
134
|
+
try:
|
|
135
|
+
if c is not None:
|
|
136
|
+
c.close()
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .base import Buffer, SpoolError, check_overflow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQLiteBuffer(Buffer):
|
|
12
|
+
"""Durable on-disk spool using only the standard library.
|
|
13
|
+
|
|
14
|
+
Survives process restarts, bounded by ``max_items`` and ``max_bytes``.
|
|
15
|
+
Use one process per database file (counters are kept in memory).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Optional[Union[str, Path]] = None, *, name: str = "default",
|
|
19
|
+
max_items: int = 100_000, max_bytes: Optional[int] = 64 * 1024 * 1024,
|
|
20
|
+
overflow: str = "drop_oldest") -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.path = Path(path) if path else Path.home() / ".richrelay" / f"{name}.db"
|
|
23
|
+
self.max_items, self.max_bytes = max_items, max_bytes
|
|
24
|
+
self.overflow = check_overflow(overflow)
|
|
25
|
+
self._lock = threading.RLock()
|
|
26
|
+
self._peek_ids: List[int] = []
|
|
27
|
+
try:
|
|
28
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self._db = sqlite3.connect(str(self.path), check_same_thread=False, timeout=10)
|
|
30
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
31
|
+
self._db.execute("PRAGMA synchronous=NORMAL")
|
|
32
|
+
self._db.execute(
|
|
33
|
+
"CREATE TABLE IF NOT EXISTS spool("
|
|
34
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL)"
|
|
35
|
+
)
|
|
36
|
+
self._db.commit()
|
|
37
|
+
row = self._db.execute(
|
|
38
|
+
"SELECT COUNT(*), COALESCE(SUM(LENGTH(payload)), 0) FROM spool"
|
|
39
|
+
).fetchone()
|
|
40
|
+
except (sqlite3.Error, OSError) as exc:
|
|
41
|
+
raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
|
|
42
|
+
self._count, self._bytes = int(row[0]), int(row[1])
|
|
43
|
+
self.known_len = self._count
|
|
44
|
+
|
|
45
|
+
def _over(self) -> bool:
|
|
46
|
+
return self._count > self.max_items or (
|
|
47
|
+
self.max_bytes is not None and self._bytes > self.max_bytes
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def push(self, payload: bytes) -> bool:
|
|
51
|
+
size = len(payload)
|
|
52
|
+
with self._lock:
|
|
53
|
+
try:
|
|
54
|
+
if self.max_bytes is not None and size > self.max_bytes:
|
|
55
|
+
self.dropped += 1
|
|
56
|
+
return False
|
|
57
|
+
if self.overflow == "drop_newest" and (
|
|
58
|
+
self._count >= self.max_items
|
|
59
|
+
or (self.max_bytes is not None and self._bytes + size > self.max_bytes)
|
|
60
|
+
):
|
|
61
|
+
self.dropped += 1
|
|
62
|
+
return False
|
|
63
|
+
self._db.execute("INSERT INTO spool(payload) VALUES (?)", (payload,))
|
|
64
|
+
self._count += 1
|
|
65
|
+
self._bytes += size
|
|
66
|
+
while self._over():
|
|
67
|
+
rows = self._db.execute(
|
|
68
|
+
"SELECT id, LENGTH(payload) FROM spool ORDER BY id LIMIT 64"
|
|
69
|
+
).fetchall()
|
|
70
|
+
last = None
|
|
71
|
+
for rid, ln in rows:
|
|
72
|
+
if not self._over():
|
|
73
|
+
break
|
|
74
|
+
last = rid
|
|
75
|
+
self._count -= 1
|
|
76
|
+
self._bytes -= ln
|
|
77
|
+
self.dropped += 1
|
|
78
|
+
if last is None:
|
|
79
|
+
break
|
|
80
|
+
self._db.execute("DELETE FROM spool WHERE id <= ?", (last,))
|
|
81
|
+
self._db.commit()
|
|
82
|
+
except sqlite3.Error as exc:
|
|
83
|
+
raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
|
|
84
|
+
self.known_len = self._count
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def peek(self, n: int) -> List[bytes]:
|
|
88
|
+
with self._lock:
|
|
89
|
+
try:
|
|
90
|
+
rows = self._db.execute(
|
|
91
|
+
"SELECT id, payload FROM spool ORDER BY id LIMIT ?", (n,)
|
|
92
|
+
).fetchall()
|
|
93
|
+
except sqlite3.Error as exc:
|
|
94
|
+
raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
|
|
95
|
+
self._peek_ids = [r[0] for r in rows]
|
|
96
|
+
return [bytes(r[1]) for r in rows]
|
|
97
|
+
|
|
98
|
+
def discard(self, n: int) -> None:
|
|
99
|
+
with self._lock:
|
|
100
|
+
ids = self._peek_ids[:n]
|
|
101
|
+
if not ids:
|
|
102
|
+
return
|
|
103
|
+
last = ids[-1]
|
|
104
|
+
try:
|
|
105
|
+
cnt, size = self._db.execute(
|
|
106
|
+
"SELECT COUNT(*), COALESCE(SUM(LENGTH(payload)), 0) FROM spool WHERE id <= ?",
|
|
107
|
+
(last,),
|
|
108
|
+
).fetchone()
|
|
109
|
+
self._db.execute("DELETE FROM spool WHERE id <= ?", (last,))
|
|
110
|
+
self._db.commit()
|
|
111
|
+
except sqlite3.Error as exc:
|
|
112
|
+
raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
|
|
113
|
+
self._count -= int(cnt)
|
|
114
|
+
self._bytes -= int(size)
|
|
115
|
+
self._peek_ids = self._peek_ids[n:]
|
|
116
|
+
self.known_len = self._count
|
|
117
|
+
|
|
118
|
+
def __len__(self) -> int:
|
|
119
|
+
return self._count
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
with self._lock:
|
|
123
|
+
try:
|
|
124
|
+
self._db.close()
|
|
125
|
+
except sqlite3.Error:
|
|
126
|
+
pass
|