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,222 @@
|
|
|
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
|
+
"""Server-sent events: framing, and the cursor that drives a stream.
|
|
14
|
+
|
|
15
|
+
The framing is shared by both adapters. The cursor is deliberately
|
|
16
|
+
synchronous and pull-based -- it answers "what is new since the last
|
|
17
|
+
call?" -- so the ASGI adapter can drive it from an event loop and the
|
|
18
|
+
WSGI one from a thread without either owning the logic.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import AsyncIterator
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from dataclasses import field
|
|
31
|
+
|
|
32
|
+
from taskflow_meter.api import serializers
|
|
33
|
+
from taskflow_meter.datasource.base import DataSource
|
|
34
|
+
from taskflow_meter.events import Event
|
|
35
|
+
|
|
36
|
+
#: Note the absence of ``connection: keep-alive``. It is a hop-by-hop
|
|
37
|
+
#: header, which PEP 3333 forbids an application from emitting -- and
|
|
38
|
+
#: wsgiref rightly refuses to send. Connection persistence is the
|
|
39
|
+
#: server's business, not ours.
|
|
40
|
+
SSE_HEADERS: tuple[tuple[str, str], ...] = (
|
|
41
|
+
("content-type", "text/event-stream; charset=utf-8"),
|
|
42
|
+
("cache-control", "no-cache"),
|
|
43
|
+
# nginx buffers proxied responses by default, which turns a live
|
|
44
|
+
# stream into one long silence followed by everything at once.
|
|
45
|
+
("x-accel-buffering", "no"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: Sent once, telling a browser how long to wait before reconnecting.
|
|
49
|
+
DEFAULT_RETRY_MS = 3000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def frame(
|
|
53
|
+
data: str,
|
|
54
|
+
*,
|
|
55
|
+
event: str | None = None,
|
|
56
|
+
event_id: int | str | None = None,
|
|
57
|
+
retry: int | None = None,
|
|
58
|
+
) -> bytes:
|
|
59
|
+
"""Render one SSE frame.
|
|
60
|
+
|
|
61
|
+
Multi-line data is split across ``data:`` lines, as the protocol
|
|
62
|
+
requires -- a raw newline would end the frame early and truncate
|
|
63
|
+
whatever followed it.
|
|
64
|
+
"""
|
|
65
|
+
lines: list[str] = []
|
|
66
|
+
if event is not None:
|
|
67
|
+
lines.append(f"event: {event}")
|
|
68
|
+
if event_id is not None:
|
|
69
|
+
lines.append(f"id: {event_id}")
|
|
70
|
+
if retry is not None:
|
|
71
|
+
lines.append(f"retry: {retry}")
|
|
72
|
+
lines.extend(f"data: {line}" for line in data.split("\n"))
|
|
73
|
+
return ("\n".join(lines) + "\n\n").encode()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def comment(text: str = "") -> bytes:
|
|
77
|
+
"""A frame clients ignore, used to keep the connection alive."""
|
|
78
|
+
return f": {text}\n\n".encode()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class EventCursor:
|
|
83
|
+
"""Tracks one client's position in one run's event stream."""
|
|
84
|
+
|
|
85
|
+
reader: DataSource
|
|
86
|
+
run_id: str
|
|
87
|
+
since_seq: int = 0
|
|
88
|
+
batch_limit: int = 200
|
|
89
|
+
#: Set once the flow has finished and its events have been drained.
|
|
90
|
+
complete: bool = field(default=False, init=False)
|
|
91
|
+
|
|
92
|
+
def opening(self) -> bytes:
|
|
93
|
+
"""The first bytes on the wire.
|
|
94
|
+
|
|
95
|
+
A retry hint plus a comment, so proxies that wait for output
|
|
96
|
+
before forwarding headers let the response through immediately.
|
|
97
|
+
"""
|
|
98
|
+
return frame("", retry=DEFAULT_RETRY_MS) + comment("stream open")
|
|
99
|
+
|
|
100
|
+
def poll(self) -> list[bytes]:
|
|
101
|
+
"""Return frames for everything new since the last call."""
|
|
102
|
+
page = self.reader.events_since(
|
|
103
|
+
self.run_id, since_seq=self.since_seq, limit=self.batch_limit
|
|
104
|
+
)
|
|
105
|
+
frames: list[bytes] = []
|
|
106
|
+
|
|
107
|
+
if page.truncated:
|
|
108
|
+
# The client's next event was evicted before it got here.
|
|
109
|
+
# Telling it beats letting it stitch a gap into a history it
|
|
110
|
+
# believes is continuous.
|
|
111
|
+
frames.append(
|
|
112
|
+
frame(
|
|
113
|
+
json.dumps(
|
|
114
|
+
{
|
|
115
|
+
"reason": "events_evicted",
|
|
116
|
+
"resume_from": page.oldest_seq,
|
|
117
|
+
},
|
|
118
|
+
separators=(",", ":"),
|
|
119
|
+
),
|
|
120
|
+
event="gap",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for item in page.events:
|
|
125
|
+
frames.append(self._event_frame(item))
|
|
126
|
+
self.since_seq = page.next_seq
|
|
127
|
+
|
|
128
|
+
if not page.events and self._flow_finished():
|
|
129
|
+
frames.append(frame("{}", event="end"))
|
|
130
|
+
self.complete = True
|
|
131
|
+
return frames
|
|
132
|
+
|
|
133
|
+
def heartbeat(self) -> bytes:
|
|
134
|
+
return comment("keep-alive")
|
|
135
|
+
|
|
136
|
+
def _event_frame(self, item: Event) -> bytes:
|
|
137
|
+
return frame(
|
|
138
|
+
json.dumps(serializers.event(item), separators=(",", ":")),
|
|
139
|
+
event=str(item.kind),
|
|
140
|
+
event_id=item.seq,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _flow_finished(self) -> bool:
|
|
144
|
+
snapshot = self.reader.get_flow(self.run_id)
|
|
145
|
+
return snapshot is not None and snapshot.is_finished
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class StreamResponse:
|
|
150
|
+
"""A response an adapter drives, rather than one it just writes.
|
|
151
|
+
|
|
152
|
+
Carries the headers and the cursor; how the polling loop is run is
|
|
153
|
+
the adapter's business, because an event loop and a worker thread
|
|
154
|
+
want to do it differently.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
cursor: EventCursor
|
|
158
|
+
status: int = 200
|
|
159
|
+
headers: tuple[tuple[str, str], ...] = SSE_HEADERS
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def iter_frames(
|
|
163
|
+
cursor: EventCursor,
|
|
164
|
+
*,
|
|
165
|
+
interval: float,
|
|
166
|
+
heartbeat: float,
|
|
167
|
+
) -> Iterator[bytes]:
|
|
168
|
+
"""Drive a cursor synchronously, for WSGI and its descendants.
|
|
169
|
+
|
|
170
|
+
A generator, so the server closing the iterable lands a
|
|
171
|
+
``GeneratorExit`` here and the loop stops rather than polling a
|
|
172
|
+
datasource nobody is listening to.
|
|
173
|
+
"""
|
|
174
|
+
quiet = 0.0
|
|
175
|
+
yield cursor.opening()
|
|
176
|
+
while True:
|
|
177
|
+
frames = cursor.poll()
|
|
178
|
+
yield from frames
|
|
179
|
+
if cursor.complete:
|
|
180
|
+
return
|
|
181
|
+
quiet = 0.0 if frames else quiet + interval
|
|
182
|
+
if quiet >= heartbeat:
|
|
183
|
+
yield cursor.heartbeat()
|
|
184
|
+
quiet = 0.0
|
|
185
|
+
time.sleep(interval)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def aiter_frames(
|
|
189
|
+
cursor: EventCursor,
|
|
190
|
+
*,
|
|
191
|
+
interval: float,
|
|
192
|
+
heartbeat: float,
|
|
193
|
+
stop: asyncio.Event | None = None,
|
|
194
|
+
) -> AsyncIterator[bytes]:
|
|
195
|
+
"""Drive a cursor from an event loop, for ASGI and its descendants.
|
|
196
|
+
|
|
197
|
+
The polling itself is handed to a thread: a datasource read is
|
|
198
|
+
blocking, and doing it inline would stall every other request the
|
|
199
|
+
loop is serving.
|
|
200
|
+
"""
|
|
201
|
+
quiet = 0.0
|
|
202
|
+
yield cursor.opening()
|
|
203
|
+
while stop is None or not stop.is_set():
|
|
204
|
+
frames = await asyncio.to_thread(cursor.poll)
|
|
205
|
+
for chunk in frames:
|
|
206
|
+
if stop is not None and stop.is_set():
|
|
207
|
+
return
|
|
208
|
+
yield chunk
|
|
209
|
+
if cursor.complete:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
quiet = 0.0 if frames else quiet + interval
|
|
213
|
+
if quiet >= heartbeat:
|
|
214
|
+
yield cursor.heartbeat()
|
|
215
|
+
quiet = 0.0
|
|
216
|
+
|
|
217
|
+
if stop is None:
|
|
218
|
+
await asyncio.sleep(interval)
|
|
219
|
+
else:
|
|
220
|
+
# Sleep, but wake immediately if the client leaves.
|
|
221
|
+
with contextlib.suppress(TimeoutError):
|
|
222
|
+
await asyncio.wait_for(stop.wait(), interval)
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
"""A plain WSGI callable, mountable anywhere.
|
|
14
|
+
|
|
15
|
+
The same service and dispatcher the ASGI app uses, so the two cannot
|
|
16
|
+
answer the same request differently -- a parity suite compares them byte
|
|
17
|
+
for byte.
|
|
18
|
+
|
|
19
|
+
Mount handling is simpler here than under ASGI: WSGI has always split
|
|
20
|
+
``SCRIPT_NAME`` from ``PATH_INFO``, so there is one convention rather
|
|
21
|
+
than three. Streaming is the harder half. A synchronous worker holds
|
|
22
|
+
one thread for as long as an SSE response stays open, so a deployment
|
|
23
|
+
serving streams from this callable wants gevent or eventlet workers --
|
|
24
|
+
or should let clients poll ``/events?since_seq=`` instead, which costs
|
|
25
|
+
nothing while idle.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from collections.abc import Iterable
|
|
33
|
+
from collections.abc import Iterator
|
|
34
|
+
from http import HTTPStatus
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from taskflow_meter.api.dispatch import Dispatcher
|
|
38
|
+
from taskflow_meter.api.http import MeterRequest
|
|
39
|
+
from taskflow_meter.api.service import MeterService
|
|
40
|
+
from taskflow_meter.api.sse import StreamResponse
|
|
41
|
+
from taskflow_meter.api.sse import iter_frames
|
|
42
|
+
from taskflow_meter.meter import Meter
|
|
43
|
+
|
|
44
|
+
LOG = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
Environ = dict[str, Any]
|
|
47
|
+
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
|
48
|
+
|
|
49
|
+
DEFAULT_STREAM_INTERVAL = 1.0
|
|
50
|
+
DEFAULT_HEARTBEAT = 15.0
|
|
51
|
+
|
|
52
|
+
#: Honoured when a reverse proxy strips a prefix the app never sees.
|
|
53
|
+
PREFIX_HEADER = "HTTP_X_FORWARDED_PREFIX"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WSGIApp:
|
|
57
|
+
"""Serves the meter over WSGI, standalone or mounted."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
meter: Meter,
|
|
62
|
+
*,
|
|
63
|
+
service: MeterService | None = None,
|
|
64
|
+
stream_interval: float = DEFAULT_STREAM_INTERVAL,
|
|
65
|
+
heartbeat: float = DEFAULT_HEARTBEAT,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.meter = meter
|
|
68
|
+
self.service = service or MeterService(meter)
|
|
69
|
+
self.dispatcher = Dispatcher(self.service)
|
|
70
|
+
self.stream_interval = stream_interval
|
|
71
|
+
self.heartbeat = heartbeat
|
|
72
|
+
|
|
73
|
+
def __call__(
|
|
74
|
+
self, environ: Environ, start_response: StartResponse
|
|
75
|
+
) -> Iterable[bytes]:
|
|
76
|
+
# WSGI has no lifespan at all, so the first request is the only
|
|
77
|
+
# place to notice that nobody started the meter.
|
|
78
|
+
self.meter.ensure_started()
|
|
79
|
+
|
|
80
|
+
result = self.dispatcher.dispatch(build_request(environ))
|
|
81
|
+
if isinstance(result, StreamResponse):
|
|
82
|
+
return self._stream(result, start_response)
|
|
83
|
+
|
|
84
|
+
start_response(status_line(result.status), list(result.headers))
|
|
85
|
+
return [result.body]
|
|
86
|
+
|
|
87
|
+
def _stream(
|
|
88
|
+
self, stream: StreamResponse, start_response: StartResponse
|
|
89
|
+
) -> Iterator[bytes]:
|
|
90
|
+
start_response(status_line(stream.status), list(stream.headers))
|
|
91
|
+
return self._frames(stream)
|
|
92
|
+
|
|
93
|
+
def _frames(self, stream: StreamResponse) -> Iterator[bytes]:
|
|
94
|
+
"""Yield frames until the flow ends or the client goes away."""
|
|
95
|
+
try:
|
|
96
|
+
yield from iter_frames(
|
|
97
|
+
stream.cursor,
|
|
98
|
+
interval=self.stream_interval,
|
|
99
|
+
heartbeat=self.heartbeat,
|
|
100
|
+
)
|
|
101
|
+
except GeneratorExit:
|
|
102
|
+
LOG.debug(
|
|
103
|
+
"client closed the stream for run %s", stream.cursor.run_id
|
|
104
|
+
)
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def status_line(status: int) -> str:
|
|
109
|
+
"""Render ``200`` as ``"200 OK"``, which is what WSGI expects."""
|
|
110
|
+
try:
|
|
111
|
+
return f"{status} {HTTPStatus(status).phrase}"
|
|
112
|
+
except ValueError: # pragma: no cover - defensive
|
|
113
|
+
return f"{status} Unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_request(environ: Environ) -> MeterRequest:
|
|
117
|
+
"""Turn a WSGI environ into a framework-neutral request."""
|
|
118
|
+
# The server split these for us; PATH_INFO is empty when the request
|
|
119
|
+
# lands exactly on the mount point.
|
|
120
|
+
script_name = environ.get("SCRIPT_NAME", "")
|
|
121
|
+
path = environ.get("PATH_INFO", "") or "/"
|
|
122
|
+
return MeterRequest.from_query_string(
|
|
123
|
+
environ.get("QUERY_STRING", ""),
|
|
124
|
+
method=environ.get("REQUEST_METHOD", "GET"),
|
|
125
|
+
path=path,
|
|
126
|
+
prefix=environ.get(PREFIX_HEADER, "") + script_name,
|
|
127
|
+
headers=request_headers(environ),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def request_headers(environ: Environ) -> dict[str, str]:
|
|
132
|
+
"""Recover header names from the CGI-style keys WSGI hands over."""
|
|
133
|
+
headers = {
|
|
134
|
+
key[5:].replace("_", "-").lower(): value
|
|
135
|
+
for key, value in environ.items()
|
|
136
|
+
if key.startswith("HTTP_")
|
|
137
|
+
}
|
|
138
|
+
for key, name in (
|
|
139
|
+
("CONTENT_TYPE", "content-type"),
|
|
140
|
+
("CONTENT_LENGTH", "content-length"),
|
|
141
|
+
):
|
|
142
|
+
# These two lose their HTTP_ prefix on the way into WSGI.
|
|
143
|
+
if key in environ:
|
|
144
|
+
headers[name] = environ[key]
|
|
145
|
+
return headers
|
taskflow_meter/cli.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
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
|
+
"""Command line entry point for ``taskflow-meter``.
|
|
14
|
+
|
|
15
|
+
Three commands, which between them cover both deployments:
|
|
16
|
+
|
|
17
|
+
``serve``
|
|
18
|
+
The API. Runs on :mod:`wsgiref`, which is in the standard library,
|
|
19
|
+
so a monitoring server costs no dependency at all. It is a
|
|
20
|
+
development server: fine for a laptop, a container sidecar or a
|
|
21
|
+
look at a staging logbook, but a real deployment should put the
|
|
22
|
+
same callable behind gunicorn or mount it in a service it runs.
|
|
23
|
+
|
|
24
|
+
``collect``
|
|
25
|
+
The other half of a multi-process deployment: consume events from a
|
|
26
|
+
broker and write them to a shared store, so the flows publish once
|
|
27
|
+
and any number of API workers read without polling anything.
|
|
28
|
+
|
|
29
|
+
``upgrade``
|
|
30
|
+
Bring that store's schema up to date.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import logging
|
|
37
|
+
import socketserver
|
|
38
|
+
from collections.abc import Sequence
|
|
39
|
+
from typing import Any
|
|
40
|
+
from wsgiref.simple_server import WSGIRequestHandler
|
|
41
|
+
from wsgiref.simple_server import WSGIServer
|
|
42
|
+
from wsgiref.simple_server import make_server
|
|
43
|
+
|
|
44
|
+
from taskflow_meter import __version__
|
|
45
|
+
from taskflow_meter.api.wsgi import WSGIApp
|
|
46
|
+
from taskflow_meter.datasource.persistence import PersistenceDataSource
|
|
47
|
+
from taskflow_meter.meter import Meter
|
|
48
|
+
from taskflow_meter.poller import DEFAULT_INTERVAL
|
|
49
|
+
|
|
50
|
+
LOG = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
#: Localhost by default: a monitoring API exposes flow names, atom
|
|
53
|
+
#: names and failure detail, none of which belongs on 0.0.0.0 by
|
|
54
|
+
#: accident.
|
|
55
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
56
|
+
DEFAULT_PORT = 8080
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ThreadingWSGIServer(socketserver.ThreadingMixIn, WSGIServer):
|
|
60
|
+
"""One thread per connection.
|
|
61
|
+
|
|
62
|
+
The single-threaded default would let one open SSE stream block
|
|
63
|
+
every other request for as long as it stayed connected.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
daemon_threads = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
prog="taskflow-meter",
|
|
72
|
+
description="Monitor OpenStack TaskFlow flow execution progress.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--version",
|
|
76
|
+
action="version",
|
|
77
|
+
version=f"%(prog)s {__version__}",
|
|
78
|
+
)
|
|
79
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
80
|
+
|
|
81
|
+
serve = subcommands.add_parser(
|
|
82
|
+
"serve",
|
|
83
|
+
help="serve the monitoring API over HTTP",
|
|
84
|
+
description=(
|
|
85
|
+
"Read a taskflow persistence backend and serve the "
|
|
86
|
+
"monitoring API. A development server; put the WSGI "
|
|
87
|
+
"callable behind a real one for anything else."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
serve.add_argument(
|
|
91
|
+
"--connection",
|
|
92
|
+
metavar="URL",
|
|
93
|
+
help=(
|
|
94
|
+
"taskflow persistence connection, as the flows themselves "
|
|
95
|
+
"use it (for example sqlite:///taskflow.db)"
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
serve.add_argument("--host", default=DEFAULT_HOST)
|
|
99
|
+
serve.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
100
|
+
serve.add_argument(
|
|
101
|
+
"--interval",
|
|
102
|
+
type=float,
|
|
103
|
+
default=DEFAULT_INTERVAL,
|
|
104
|
+
help="seconds between polls of the backend",
|
|
105
|
+
)
|
|
106
|
+
serve.add_argument(
|
|
107
|
+
"--no-poll",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help=(
|
|
110
|
+
"read the backend directly instead of polling it; no event "
|
|
111
|
+
"history, and the stream endpoints report 501"
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
serve.add_argument(
|
|
115
|
+
"--store-url",
|
|
116
|
+
metavar="URL",
|
|
117
|
+
help=(
|
|
118
|
+
"read the meter's own database instead, as filled by a "
|
|
119
|
+
"collect process; implies no polling"
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
serve.set_defaults(handler=serve_command)
|
|
123
|
+
|
|
124
|
+
collect = subcommands.add_parser(
|
|
125
|
+
"collect",
|
|
126
|
+
help="consume events from a broker into the meter's database",
|
|
127
|
+
description=(
|
|
128
|
+
"The collector half of a multi-process deployment. Flows "
|
|
129
|
+
"publish events to a broker; this writes them to a store "
|
|
130
|
+
"the API workers read."
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
collect.add_argument(
|
|
134
|
+
"--amqp-url",
|
|
135
|
+
required=True,
|
|
136
|
+
metavar="URL",
|
|
137
|
+
help="broker to consume from, for example amqp://host//",
|
|
138
|
+
)
|
|
139
|
+
collect.add_argument(
|
|
140
|
+
"--store-url",
|
|
141
|
+
required=True,
|
|
142
|
+
metavar="URL",
|
|
143
|
+
help="the meter's own database to write to",
|
|
144
|
+
)
|
|
145
|
+
collect.add_argument(
|
|
146
|
+
"--create-schema",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="create the tables if missing, instead of migrating",
|
|
149
|
+
)
|
|
150
|
+
collect.add_argument(
|
|
151
|
+
"--once",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="drain whatever is queued and exit, rather than looping",
|
|
154
|
+
)
|
|
155
|
+
collect.add_argument(
|
|
156
|
+
"--timeout",
|
|
157
|
+
type=float,
|
|
158
|
+
default=1.0,
|
|
159
|
+
help="seconds to wait for a batch before looking again",
|
|
160
|
+
)
|
|
161
|
+
collect.set_defaults(handler=collect_command)
|
|
162
|
+
|
|
163
|
+
upgrade = subcommands.add_parser(
|
|
164
|
+
"upgrade",
|
|
165
|
+
help="bring the meter's own database up to date",
|
|
166
|
+
)
|
|
167
|
+
upgrade.add_argument("--store-url", required=True, metavar="URL")
|
|
168
|
+
upgrade.set_defaults(handler=upgrade_command)
|
|
169
|
+
return parser
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_app(
|
|
173
|
+
connection: str | None = None,
|
|
174
|
+
*,
|
|
175
|
+
poll: bool = True,
|
|
176
|
+
interval: float = DEFAULT_INTERVAL,
|
|
177
|
+
store_url: str | None = None,
|
|
178
|
+
) -> WSGIApp:
|
|
179
|
+
"""Build the callable for whichever deployment was asked for.
|
|
180
|
+
|
|
181
|
+
With ``store_url`` the meter reads a store a collector fills, and
|
|
182
|
+
polls nothing. Otherwise it watches a taskflow backend directly.
|
|
183
|
+
"""
|
|
184
|
+
if store_url is not None:
|
|
185
|
+
return WSGIApp(Meter(build_store(store_url), poll=False))
|
|
186
|
+
if connection is None:
|
|
187
|
+
msg = "pass --connection or --store-url"
|
|
188
|
+
raise ValueError(msg)
|
|
189
|
+
source = PersistenceDataSource(conf={"connection": connection})
|
|
190
|
+
return WSGIApp(Meter(source, poll=poll, interval=interval))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_store(url: str, *, create_schema: bool = False) -> Any:
|
|
194
|
+
"""Open the meter's own database.
|
|
195
|
+
|
|
196
|
+
Imported here rather than at module scope: SQLAlchemy is an extra,
|
|
197
|
+
and `taskflow-meter serve` against a taskflow backend must not
|
|
198
|
+
require it.
|
|
199
|
+
"""
|
|
200
|
+
from taskflow_meter.datasource.sqlalchemy import SQLADataSource
|
|
201
|
+
|
|
202
|
+
return SQLADataSource(url, create_schema=create_schema)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def serve_command(args: argparse.Namespace) -> int:
|
|
206
|
+
logging.basicConfig(
|
|
207
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
|
208
|
+
)
|
|
209
|
+
app = build_app(
|
|
210
|
+
args.connection,
|
|
211
|
+
poll=not args.no_poll,
|
|
212
|
+
interval=args.interval,
|
|
213
|
+
store_url=args.store_url,
|
|
214
|
+
)
|
|
215
|
+
with (
|
|
216
|
+
app.meter,
|
|
217
|
+
make_server(
|
|
218
|
+
args.host,
|
|
219
|
+
args.port,
|
|
220
|
+
app,
|
|
221
|
+
server_class=ThreadingWSGIServer,
|
|
222
|
+
handler_class=WSGIRequestHandler,
|
|
223
|
+
) as server,
|
|
224
|
+
):
|
|
225
|
+
host, port = server.server_address[:2]
|
|
226
|
+
LOG.info("serving on http://%s:%s", host, port)
|
|
227
|
+
try:
|
|
228
|
+
server.serve_forever()
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
LOG.info("shutting down")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def collect_command(args: argparse.Namespace) -> int:
|
|
235
|
+
"""Consume from the broker until interrupted."""
|
|
236
|
+
logging.basicConfig(
|
|
237
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
|
238
|
+
)
|
|
239
|
+
from taskflow_meter.transports.amqp import AMQPSubscriber
|
|
240
|
+
|
|
241
|
+
store = build_store(args.store_url, create_schema=args.create_schema)
|
|
242
|
+
subscriber = AMQPSubscriber(args.amqp_url)
|
|
243
|
+
total = 0
|
|
244
|
+
|
|
245
|
+
with store, subscriber:
|
|
246
|
+
LOG.info("collecting from %s", args.amqp_url)
|
|
247
|
+
try:
|
|
248
|
+
while True:
|
|
249
|
+
received = subscriber.consume(
|
|
250
|
+
store.apply_many, timeout=args.timeout
|
|
251
|
+
)
|
|
252
|
+
total += received
|
|
253
|
+
if received:
|
|
254
|
+
LOG.info("stored %d events (%d total)", received, total)
|
|
255
|
+
if args.once:
|
|
256
|
+
break
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
LOG.info("shutting down after %d events", total)
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def upgrade_command(args: argparse.Namespace) -> int:
|
|
263
|
+
"""Run the migrations against the meter's own database."""
|
|
264
|
+
logging.basicConfig(level=logging.INFO)
|
|
265
|
+
from taskflow_meter.datasource.sqlalchemy import upgrade
|
|
266
|
+
|
|
267
|
+
upgrade(args.store_url)
|
|
268
|
+
LOG.info("schema is up to date")
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
273
|
+
parser = build_parser()
|
|
274
|
+
args = parser.parse_args(argv)
|
|
275
|
+
handler = getattr(args, "handler", None)
|
|
276
|
+
if handler is None:
|
|
277
|
+
parser.print_help()
|
|
278
|
+
return 0
|
|
279
|
+
if args.command == "serve" and not (args.connection or args.store_url):
|
|
280
|
+
# Neither source given: a usage error, not a traceback.
|
|
281
|
+
parser.error("serve needs --connection or --store-url")
|
|
282
|
+
result: int = handler(args)
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__": # pragma: no cover
|
|
287
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
"""The emit side: watch an engine as it runs."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from taskflow_meter.collect.attachment import Attachment
|
|
18
|
+
from taskflow_meter.collect.attachment import attach
|
|
19
|
+
from taskflow_meter.collect.attachment import describe_graph
|
|
20
|
+
from taskflow_meter.collect.listener import MeterListener
|
|
21
|
+
from taskflow_meter.collect.pipeline import EventPipeline
|
|
22
|
+
from taskflow_meter.collect.progress import ProgressTap
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Attachment",
|
|
26
|
+
"EventPipeline",
|
|
27
|
+
"MeterListener",
|
|
28
|
+
"ProgressTap",
|
|
29
|
+
"attach",
|
|
30
|
+
"describe_graph",
|
|
31
|
+
]
|