taskflow-meter 1.0.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.
- taskflow_meter/__init__.py +47 -0
- taskflow_meter/_version.py +24 -0
- taskflow_meter/api/__init__.py +35 -0
- taskflow_meter/api/asgi.py +236 -0
- taskflow_meter/api/dispatch.py +128 -0
- taskflow_meter/api/http.py +210 -0
- taskflow_meter/api/router.py +113 -0
- taskflow_meter/api/routes.py +53 -0
- taskflow_meter/api/serializers.py +137 -0
- taskflow_meter/api/service.py +189 -0
- taskflow_meter/api/sse.py +222 -0
- taskflow_meter/api/wsgi.py +145 -0
- taskflow_meter/cli.py +287 -0
- taskflow_meter/collect/__init__.py +31 -0
- taskflow_meter/collect/attachment.py +208 -0
- taskflow_meter/collect/listener.py +161 -0
- taskflow_meter/collect/pipeline.py +229 -0
- taskflow_meter/collect/progress.py +170 -0
- taskflow_meter/conf.py +173 -0
- taskflow_meter/contrib/__init__.py +18 -0
- taskflow_meter/contrib/django.py +160 -0
- taskflow_meter/contrib/fastapi.py +149 -0
- taskflow_meter/contrib/flask.py +140 -0
- taskflow_meter/contrib/paste.py +96 -0
- taskflow_meter/contrib/pecan.py +84 -0
- taskflow_meter/datasource/__init__.py +33 -0
- taskflow_meter/datasource/base.py +154 -0
- taskflow_meter/datasource/memory.py +232 -0
- taskflow_meter/datasource/persistence.py +311 -0
- taskflow_meter/datasource/sqlalchemy/__init__.py +21 -0
- taskflow_meter/datasource/sqlalchemy/migrations/env.py +68 -0
- taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +25 -0
- taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +71 -0
- taskflow_meter/datasource/sqlalchemy/models.py +63 -0
- taskflow_meter/datasource/sqlalchemy/source.py +367 -0
- taskflow_meter/diff.py +223 -0
- taskflow_meter/events.py +129 -0
- taskflow_meter/fold.py +137 -0
- taskflow_meter/meter.py +255 -0
- taskflow_meter/models.py +143 -0
- taskflow_meter/poller.py +191 -0
- taskflow_meter/py.typed +0 -0
- taskflow_meter/states.py +60 -0
- taskflow_meter/transports/__init__.py +21 -0
- taskflow_meter/transports/amqp.py +204 -0
- taskflow_meter/transports/base.py +105 -0
- taskflow_meter/transports/http.py +97 -0
- taskflow_meter/transports/memory.py +83 -0
- taskflow_meter-1.0.0.dist-info/METADATA +258 -0
- taskflow_meter-1.0.0.dist-info/RECORD +53 -0
- taskflow_meter-1.0.0.dist-info/WHEEL +4 -0
- taskflow_meter-1.0.0.dist-info/entry_points.txt +19 -0
- taskflow_meter-1.0.0.dist-info/licenses/LICENSE +176 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""Carry events over a message broker, using kombu.
|
|
14
|
+
|
|
15
|
+
kombu rather than a raw AMQP client because taskflow already depends on
|
|
16
|
+
it for its worker-based engine, so a deployment running those flows has
|
|
17
|
+
it already.
|
|
18
|
+
|
|
19
|
+
This is the transport for the collector deployment: the processes
|
|
20
|
+
running flows publish, one collector consumes and writes to a shared
|
|
21
|
+
datasource, and the API workers read that with ``poll = false``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import kombu
|
|
32
|
+
|
|
33
|
+
from taskflow_meter.events import Event
|
|
34
|
+
from taskflow_meter.transports.base import Publisher
|
|
35
|
+
from taskflow_meter.transports.base import Subscriber
|
|
36
|
+
|
|
37
|
+
LOG = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
DEFAULT_EXCHANGE = "taskflow-meter"
|
|
40
|
+
DEFAULT_QUEUE = "taskflow-meter-events"
|
|
41
|
+
DEFAULT_ROUTING_KEY = "events"
|
|
42
|
+
DEFAULT_TIMEOUT = 1.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_exchange(name: str = DEFAULT_EXCHANGE) -> kombu.Exchange:
|
|
46
|
+
return kombu.Exchange(name, type="direct", durable=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_queue(
|
|
50
|
+
name: str = DEFAULT_QUEUE,
|
|
51
|
+
*,
|
|
52
|
+
exchange: str = DEFAULT_EXCHANGE,
|
|
53
|
+
routing_key: str = DEFAULT_ROUTING_KEY,
|
|
54
|
+
) -> kombu.Queue:
|
|
55
|
+
"""Durable, so events survive a collector restart.
|
|
56
|
+
|
|
57
|
+
A monitoring queue that discards what it could not deliver is a
|
|
58
|
+
monitoring queue that lies about the gap.
|
|
59
|
+
"""
|
|
60
|
+
return kombu.Queue(
|
|
61
|
+
name,
|
|
62
|
+
exchange=build_exchange(exchange),
|
|
63
|
+
routing_key=routing_key,
|
|
64
|
+
durable=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AMQPTransport(Publisher):
|
|
69
|
+
"""Publishes batches to an exchange."""
|
|
70
|
+
|
|
71
|
+
name = "amqp"
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
url: str,
|
|
76
|
+
*,
|
|
77
|
+
exchange: str = DEFAULT_EXCHANGE,
|
|
78
|
+
queue: str = DEFAULT_QUEUE,
|
|
79
|
+
routing_key: str = DEFAULT_ROUTING_KEY,
|
|
80
|
+
connection: kombu.Connection | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.url = url
|
|
83
|
+
self.routing_key = routing_key
|
|
84
|
+
self._exchange = build_exchange(exchange)
|
|
85
|
+
self._queue = build_queue(
|
|
86
|
+
queue, exchange=exchange, routing_key=routing_key
|
|
87
|
+
)
|
|
88
|
+
self._connection = connection
|
|
89
|
+
self._owns_connection = connection is None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def connection(self) -> kombu.Connection:
|
|
93
|
+
if self._connection is None:
|
|
94
|
+
self._connection = kombu.Connection(self.url)
|
|
95
|
+
return self._connection
|
|
96
|
+
|
|
97
|
+
def stop(self) -> None:
|
|
98
|
+
if self._owns_connection and self._connection is not None:
|
|
99
|
+
self._connection.release()
|
|
100
|
+
self._connection = None
|
|
101
|
+
|
|
102
|
+
def publish(self, events: Sequence[Event]) -> None:
|
|
103
|
+
payload = {"events": [event.to_dict() for event in events]}
|
|
104
|
+
producer = self.connection.Producer(serializer="json")
|
|
105
|
+
producer.publish(
|
|
106
|
+
payload,
|
|
107
|
+
exchange=self._exchange,
|
|
108
|
+
routing_key=self.routing_key,
|
|
109
|
+
# Declared on every publish: the exchange and queue may not
|
|
110
|
+
# exist yet if a flow starts before the collector does, and
|
|
111
|
+
# events published into nothing are simply lost.
|
|
112
|
+
declare=[self._queue],
|
|
113
|
+
retry=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AMQPSubscriber(Subscriber):
|
|
118
|
+
"""Consumes batches from a queue."""
|
|
119
|
+
|
|
120
|
+
name = "amqp"
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
url: str,
|
|
125
|
+
*,
|
|
126
|
+
exchange: str = DEFAULT_EXCHANGE,
|
|
127
|
+
queue: str = DEFAULT_QUEUE,
|
|
128
|
+
routing_key: str = DEFAULT_ROUTING_KEY,
|
|
129
|
+
connection: kombu.Connection | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.url = url
|
|
132
|
+
self._queue = build_queue(
|
|
133
|
+
queue, exchange=exchange, routing_key=routing_key
|
|
134
|
+
)
|
|
135
|
+
self._connection = connection
|
|
136
|
+
self._owns_connection = connection is None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def connection(self) -> kombu.Connection:
|
|
140
|
+
if self._connection is None:
|
|
141
|
+
self._connection = kombu.Connection(self.url)
|
|
142
|
+
return self._connection
|
|
143
|
+
|
|
144
|
+
def stop(self) -> None:
|
|
145
|
+
if self._owns_connection and self._connection is not None:
|
|
146
|
+
self._connection.release()
|
|
147
|
+
self._connection = None
|
|
148
|
+
|
|
149
|
+
def consume(
|
|
150
|
+
self,
|
|
151
|
+
handler: Callable[[Sequence[Event]], None],
|
|
152
|
+
*,
|
|
153
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
154
|
+
) -> int:
|
|
155
|
+
"""Deliver what has arrived, and return how many events."""
|
|
156
|
+
received = 0
|
|
157
|
+
give_up = False
|
|
158
|
+
|
|
159
|
+
def on_message(body: Any, message: Any) -> None:
|
|
160
|
+
nonlocal received, give_up
|
|
161
|
+
try:
|
|
162
|
+
events = _decode(body)
|
|
163
|
+
except Exception:
|
|
164
|
+
# A message we cannot read will never become readable.
|
|
165
|
+
# Rejecting it beats redelivering it forever.
|
|
166
|
+
LOG.exception("discarding an unreadable message")
|
|
167
|
+
message.reject()
|
|
168
|
+
return
|
|
169
|
+
try:
|
|
170
|
+
handler(events)
|
|
171
|
+
except Exception:
|
|
172
|
+
# Leave it on the queue: the handler may recover, and
|
|
173
|
+
# dropping monitoring data silently is the one outcome
|
|
174
|
+
# worse than redelivering it. Then stop draining --
|
|
175
|
+
# a requeued message is redelivered immediately, so
|
|
176
|
+
# carrying on would spin on it until the handler
|
|
177
|
+
# recovered, which it cannot while we never return.
|
|
178
|
+
LOG.exception("handler failed; leaving the message")
|
|
179
|
+
message.requeue()
|
|
180
|
+
give_up = True
|
|
181
|
+
return
|
|
182
|
+
received += len(events)
|
|
183
|
+
message.ack()
|
|
184
|
+
|
|
185
|
+
with self.connection.Consumer(
|
|
186
|
+
[self._queue], callbacks=[on_message], accept=["json"]
|
|
187
|
+
):
|
|
188
|
+
while not give_up:
|
|
189
|
+
try:
|
|
190
|
+
self.connection.drain_events(timeout=timeout)
|
|
191
|
+
except TimeoutError:
|
|
192
|
+
break
|
|
193
|
+
except OSError: # pragma: no cover - broker went away
|
|
194
|
+
LOG.exception("lost the broker connection")
|
|
195
|
+
break
|
|
196
|
+
return received
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _decode(body: Any) -> list[Event]:
|
|
200
|
+
if isinstance(body, str):
|
|
201
|
+
import json
|
|
202
|
+
|
|
203
|
+
body = json.loads(body)
|
|
204
|
+
return [Event.from_dict(item) for item in body["events"]]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""Where events go once they leave the process that produced them.
|
|
14
|
+
|
|
15
|
+
A publisher is handed batches, never single events: the pipeline drains
|
|
16
|
+
its queue in one go, so a transport that can amortise a round trip gets
|
|
17
|
+
the chance to.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import abc
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
from typing import Self
|
|
27
|
+
|
|
28
|
+
from taskflow_meter.events import Event
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Publisher(abc.ABC):
|
|
32
|
+
"""Sends events somewhere.
|
|
33
|
+
|
|
34
|
+
Implementations may raise: the pipeline that drives them counts and
|
|
35
|
+
logs failures rather than letting one reach the flow being watched.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
#: Stevedore plugin name, set by subclasses.
|
|
39
|
+
name: str = ""
|
|
40
|
+
|
|
41
|
+
def start(self) -> None: # noqa: B027 - optional hook, not abstract
|
|
42
|
+
"""Acquire whatever the transport needs. Idempotent."""
|
|
43
|
+
|
|
44
|
+
def stop(self) -> None: # noqa: B027 - optional hook, not abstract
|
|
45
|
+
"""Release it again. Idempotent, and safe to call unstarted."""
|
|
46
|
+
|
|
47
|
+
@abc.abstractmethod
|
|
48
|
+
def publish(self, events: Sequence[Event]) -> None:
|
|
49
|
+
"""Send a batch. Never called with an empty one."""
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> Self:
|
|
52
|
+
self.start()
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(
|
|
56
|
+
self,
|
|
57
|
+
exc_type: type[BaseException] | None,
|
|
58
|
+
exc: BaseException | None,
|
|
59
|
+
tb: TracebackType | None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.stop()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Subscriber(abc.ABC):
|
|
65
|
+
"""Receives events somebody else published.
|
|
66
|
+
|
|
67
|
+
The collector's end of a transport: one process consumes what the
|
|
68
|
+
flows emitted and writes it to a datasource that any number of API
|
|
69
|
+
workers read.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
#: Stevedore plugin name, set by subclasses.
|
|
73
|
+
name: str = ""
|
|
74
|
+
|
|
75
|
+
def start(self) -> None: # noqa: B027 - optional hook, not abstract
|
|
76
|
+
"""Acquire whatever the transport needs. Idempotent."""
|
|
77
|
+
|
|
78
|
+
def stop(self) -> None: # noqa: B027 - optional hook, not abstract
|
|
79
|
+
"""Release it again. Idempotent, and safe to call unstarted."""
|
|
80
|
+
|
|
81
|
+
@abc.abstractmethod
|
|
82
|
+
def consume(
|
|
83
|
+
self,
|
|
84
|
+
handler: Callable[[Sequence[Event]], None],
|
|
85
|
+
*,
|
|
86
|
+
timeout: float | None = None,
|
|
87
|
+
) -> int:
|
|
88
|
+
"""Deliver whatever has arrived, and return how many events.
|
|
89
|
+
|
|
90
|
+
Returns when nothing more arrives within ``timeout``, so the
|
|
91
|
+
caller owns the loop -- and can shut it down between batches
|
|
92
|
+
rather than being trapped inside one.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> Self:
|
|
96
|
+
self.start()
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(
|
|
100
|
+
self,
|
|
101
|
+
exc_type: type[BaseException] | None,
|
|
102
|
+
exc: BaseException | None,
|
|
103
|
+
tb: TracebackType | None,
|
|
104
|
+
) -> None:
|
|
105
|
+
self.stop()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""Post batches of events to a webhook.
|
|
14
|
+
|
|
15
|
+
Uses :mod:`urllib.request` rather than a HTTP client library: this is
|
|
16
|
+
one POST of a JSON array, and a monitoring sidecar is a poor reason to
|
|
17
|
+
put another dependency into somebody's service.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
from taskflow_meter.events import Event
|
|
30
|
+
from taskflow_meter.transports.base import Publisher
|
|
31
|
+
|
|
32
|
+
LOG = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
DEFAULT_TIMEOUT = 5.0
|
|
35
|
+
DEFAULT_RETRIES = 2
|
|
36
|
+
DEFAULT_BACKOFF = 0.2
|
|
37
|
+
|
|
38
|
+
#: Statuses worth trying again. A 4xx means the request was wrong and
|
|
39
|
+
#: will be just as wrong the second time.
|
|
40
|
+
RETRYABLE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HTTPTransport(Publisher):
|
|
44
|
+
"""POSTs ``{"events": [...]}`` to a URL."""
|
|
45
|
+
|
|
46
|
+
name = "http"
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
url: str,
|
|
51
|
+
*,
|
|
52
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
53
|
+
retries: int = DEFAULT_RETRIES,
|
|
54
|
+
backoff: float = DEFAULT_BACKOFF,
|
|
55
|
+
headers: dict[str, str] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if retries < 0:
|
|
58
|
+
msg = "retries cannot be negative"
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
self.url = url
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self.retries = retries
|
|
63
|
+
self.backoff = backoff
|
|
64
|
+
self.headers = {"content-type": "application/json", **(headers or {})}
|
|
65
|
+
|
|
66
|
+
def publish(self, events: Sequence[Event]) -> None:
|
|
67
|
+
body = json.dumps(
|
|
68
|
+
{"events": [event.to_dict() for event in events]},
|
|
69
|
+
separators=(",", ":"),
|
|
70
|
+
).encode()
|
|
71
|
+
|
|
72
|
+
last: Exception | None = None
|
|
73
|
+
for attempt in range(self.retries + 1):
|
|
74
|
+
try:
|
|
75
|
+
self._post(body)
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
last = exc
|
|
78
|
+
if exc.code not in RETRYABLE_STATUSES:
|
|
79
|
+
# Retrying a rejected request just rejects it again.
|
|
80
|
+
raise
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
last = exc
|
|
83
|
+
else:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if attempt < self.retries:
|
|
87
|
+
time.sleep(self.backoff * (2**attempt))
|
|
88
|
+
|
|
89
|
+
assert last is not None
|
|
90
|
+
raise last
|
|
91
|
+
|
|
92
|
+
def _post(self, body: bytes) -> None:
|
|
93
|
+
request = urllib.request.Request(
|
|
94
|
+
self.url, data=body, headers=self.headers, method="POST"
|
|
95
|
+
)
|
|
96
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
97
|
+
LOG.debug("published to %s: %s", self.url, response.status)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""In-process transports: keep the events, or fold them into a store."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
from collections import deque
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
from taskflow_meter.datasource.base import WritableDataSource
|
|
22
|
+
from taskflow_meter.events import Event
|
|
23
|
+
from taskflow_meter.transports.base import Publisher
|
|
24
|
+
|
|
25
|
+
#: Events retained before the oldest are dropped.
|
|
26
|
+
DEFAULT_MAX_EVENTS = 10000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryTransport(Publisher):
|
|
30
|
+
"""Holds events in a bounded buffer for something else to drain.
|
|
31
|
+
|
|
32
|
+
Useful for tests and for handing a batch to another thread in the
|
|
33
|
+
same process. Bounded because an undrained buffer is a leak with a
|
|
34
|
+
slower fuse than a crash.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name = "memory"
|
|
38
|
+
|
|
39
|
+
def __init__(self, *, max_events: int = DEFAULT_MAX_EVENTS) -> None:
|
|
40
|
+
if max_events < 1:
|
|
41
|
+
msg = "max_events must be at least 1"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
self._events: deque[Event] = deque(maxlen=max_events)
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
self.dropped = 0
|
|
46
|
+
|
|
47
|
+
def publish(self, events: Sequence[Event]) -> None:
|
|
48
|
+
with self._lock:
|
|
49
|
+
room = self._events.maxlen or 0
|
|
50
|
+
overflow = max(0, len(self._events) + len(events) - room)
|
|
51
|
+
self.dropped += overflow
|
|
52
|
+
self._events.extend(events)
|
|
53
|
+
|
|
54
|
+
def drain(self) -> tuple[Event, ...]:
|
|
55
|
+
"""Take everything buffered so far."""
|
|
56
|
+
with self._lock:
|
|
57
|
+
taken = tuple(self._events)
|
|
58
|
+
self._events.clear()
|
|
59
|
+
return taken
|
|
60
|
+
|
|
61
|
+
def peek(self) -> tuple[Event, ...]:
|
|
62
|
+
with self._lock:
|
|
63
|
+
return tuple(self._events)
|
|
64
|
+
|
|
65
|
+
def __len__(self) -> int:
|
|
66
|
+
with self._lock:
|
|
67
|
+
return len(self._events)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DataSourcePublisher(Publisher):
|
|
71
|
+
"""Folds events straight into a writable datasource.
|
|
72
|
+
|
|
73
|
+
The whole in-process path: a listener produces events and the API
|
|
74
|
+
can serve them from the same process, with nothing on the wire.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name = "datasource"
|
|
78
|
+
|
|
79
|
+
def __init__(self, sink: WritableDataSource) -> None:
|
|
80
|
+
self.sink = sink
|
|
81
|
+
|
|
82
|
+
def publish(self, events: Sequence[Event]) -> None:
|
|
83
|
+
self.sink.apply_many(events)
|