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,232 @@
|
|
|
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 datasource: state and a bounded event history, in RAM.
|
|
14
|
+
|
|
15
|
+
Useful on its own for embedded monitoring of a single process, and it is
|
|
16
|
+
the reference implementation the other datasources are checked against --
|
|
17
|
+
folding an event stream into it must reproduce the snapshots the stream was
|
|
18
|
+
derived from.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import threading
|
|
25
|
+
from collections import deque
|
|
26
|
+
from dataclasses import replace
|
|
27
|
+
|
|
28
|
+
from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
|
|
29
|
+
from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
|
|
30
|
+
from taskflow_meter.datasource.base import EventPage
|
|
31
|
+
from taskflow_meter.datasource.base import FlowPage
|
|
32
|
+
from taskflow_meter.datasource.base import UnknownMarkerError
|
|
33
|
+
from taskflow_meter.datasource.base import WritableDataSource
|
|
34
|
+
from taskflow_meter.events import Event
|
|
35
|
+
from taskflow_meter.fold import contiguous_from
|
|
36
|
+
from taskflow_meter.fold import flow_from_event
|
|
37
|
+
from taskflow_meter.fold import fold
|
|
38
|
+
from taskflow_meter.models import FlowSnapshot
|
|
39
|
+
|
|
40
|
+
LOG = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
#: Events retained per run before the oldest are dropped.
|
|
43
|
+
DEFAULT_MAX_EVENTS_PER_RUN = 1000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Run:
|
|
47
|
+
"""Stored state for one flow run."""
|
|
48
|
+
|
|
49
|
+
__slots__ = ("events", "flow", "highest_seq", "oldest_seq")
|
|
50
|
+
|
|
51
|
+
def __init__(self, flow: FlowSnapshot, max_events: int) -> None:
|
|
52
|
+
self.flow = flow
|
|
53
|
+
self.events: deque[Event] = deque(maxlen=max_events)
|
|
54
|
+
self.oldest_seq: int | None = None
|
|
55
|
+
self.highest_seq = 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MemoryDataSource(WritableDataSource):
|
|
59
|
+
"""Keeps every run it is told about until told to forget it."""
|
|
60
|
+
|
|
61
|
+
name = "memory"
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
max_events_per_run: int = DEFAULT_MAX_EVENTS_PER_RUN,
|
|
67
|
+
max_runs: int | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
if max_events_per_run < 1:
|
|
70
|
+
msg = "max_events_per_run must be at least 1"
|
|
71
|
+
raise ValueError(msg)
|
|
72
|
+
if max_runs is not None and max_runs < 1:
|
|
73
|
+
msg = "max_runs must be at least 1 when set"
|
|
74
|
+
raise ValueError(msg)
|
|
75
|
+
self._max_events_per_run = max_events_per_run
|
|
76
|
+
self._max_runs = max_runs
|
|
77
|
+
# Producers write from a poller or executor thread while the API
|
|
78
|
+
# reads from its own; every access below holds this.
|
|
79
|
+
self._lock = threading.RLock()
|
|
80
|
+
self._runs: dict[str, _Run] = {}
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def max_events_per_run(self) -> int:
|
|
84
|
+
"""How much history each run keeps before the oldest is dropped."""
|
|
85
|
+
return self._max_events_per_run
|
|
86
|
+
|
|
87
|
+
# -- writing ---------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def apply(self, event: Event) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
run = self._runs.get(event.run_id)
|
|
92
|
+
if run is None:
|
|
93
|
+
run = _Run(flow_from_event(event), self._max_events_per_run)
|
|
94
|
+
self._runs[event.run_id] = run
|
|
95
|
+
self._evict_if_needed()
|
|
96
|
+
|
|
97
|
+
run.flow = fold(run.flow, event)
|
|
98
|
+
self._record(run, event)
|
|
99
|
+
|
|
100
|
+
def _record(self, run: _Run, event: Event) -> None:
|
|
101
|
+
if len(run.events) == self._max_events_per_run:
|
|
102
|
+
# The deque drops the oldest for us; say which one went, so a
|
|
103
|
+
# gap reported later by events_since() can be explained.
|
|
104
|
+
LOG.debug(
|
|
105
|
+
"dropping event %d for run %s: history is full at %d",
|
|
106
|
+
run.events[0].seq,
|
|
107
|
+
event.run_id,
|
|
108
|
+
self._max_events_per_run,
|
|
109
|
+
)
|
|
110
|
+
run.events.append(event)
|
|
111
|
+
run.oldest_seq = run.events[0].seq
|
|
112
|
+
run.highest_seq = max(run.highest_seq, event.seq)
|
|
113
|
+
|
|
114
|
+
def _evict_if_needed(self) -> None:
|
|
115
|
+
if self._max_runs is None or len(self._runs) <= self._max_runs:
|
|
116
|
+
return
|
|
117
|
+
# Oldest observation first, so a long-finished run goes before a
|
|
118
|
+
# live one. Never silent: an operator needs to know the window is
|
|
119
|
+
# too small for their flow volume.
|
|
120
|
+
victim = min(self._runs.values(), key=lambda run: run.flow.observed_at)
|
|
121
|
+
del self._runs[victim.flow.run_id]
|
|
122
|
+
LOG.warning(
|
|
123
|
+
"evicted run %s: at the %d run limit",
|
|
124
|
+
victim.flow.run_id,
|
|
125
|
+
self._max_runs,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def forget(self, run_id: str) -> bool:
|
|
129
|
+
"""Drop a run entirely. Returns whether it was there."""
|
|
130
|
+
with self._lock:
|
|
131
|
+
return self._runs.pop(run_id, None) is not None
|
|
132
|
+
|
|
133
|
+
# -- reading ---------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def get_flow(self, run_id: str) -> FlowSnapshot | None:
|
|
136
|
+
with self._lock:
|
|
137
|
+
run = self._runs.get(run_id)
|
|
138
|
+
return _detach(run.flow) if run is not None else None
|
|
139
|
+
|
|
140
|
+
def list_flows(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
state: str | None = None,
|
|
144
|
+
book_id: str | None = None,
|
|
145
|
+
limit: int = DEFAULT_FLOW_LIMIT,
|
|
146
|
+
marker: str | None = None,
|
|
147
|
+
) -> FlowPage:
|
|
148
|
+
if limit < 1:
|
|
149
|
+
msg = "limit must be at least 1"
|
|
150
|
+
raise ValueError(msg)
|
|
151
|
+
|
|
152
|
+
with self._lock:
|
|
153
|
+
flows = [
|
|
154
|
+
_detach(run.flow)
|
|
155
|
+
for run in self._runs.values()
|
|
156
|
+
if (state is None or run.flow.state == state)
|
|
157
|
+
and (book_id is None or run.flow.book_id == book_id)
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
# Newest observation first; run_id breaks ties so that paging over
|
|
161
|
+
# flows observed in the same instant cannot repeat or skip one.
|
|
162
|
+
flows.sort(key=lambda flow: (-flow.observed_at, flow.run_id))
|
|
163
|
+
|
|
164
|
+
start = 0
|
|
165
|
+
if marker is not None:
|
|
166
|
+
positions = {
|
|
167
|
+
flow.run_id: index for index, flow in enumerate(flows)
|
|
168
|
+
}
|
|
169
|
+
if marker not in positions:
|
|
170
|
+
msg = f"unknown paging marker: {marker!r}"
|
|
171
|
+
raise UnknownMarkerError(msg)
|
|
172
|
+
start = positions[marker] + 1
|
|
173
|
+
|
|
174
|
+
window = flows[start : start + limit]
|
|
175
|
+
more = len(flows) > start + limit
|
|
176
|
+
return FlowPage(
|
|
177
|
+
items=tuple(window),
|
|
178
|
+
next_marker=window[-1].run_id if more and window else None,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def events_since(
|
|
182
|
+
self,
|
|
183
|
+
run_id: str,
|
|
184
|
+
*,
|
|
185
|
+
since_seq: int = 0,
|
|
186
|
+
limit: int = DEFAULT_EVENT_LIMIT,
|
|
187
|
+
) -> EventPage:
|
|
188
|
+
"""Return the contiguous run of events after ``since_seq``.
|
|
189
|
+
|
|
190
|
+
Contiguous, and in sequence order, because concurrent producers
|
|
191
|
+
do not arrive in it: two threads under the parallel engine each
|
|
192
|
+
allocate a number and then enqueue, and the enqueues can
|
|
193
|
+
invert. Returning event 8 while 7 is still in flight would
|
|
194
|
+
advance the caller past 7 for good, so anything beyond a gap is
|
|
195
|
+
held back until the gap fills.
|
|
196
|
+
"""
|
|
197
|
+
if limit < 1:
|
|
198
|
+
msg = "limit must be at least 1"
|
|
199
|
+
raise ValueError(msg)
|
|
200
|
+
|
|
201
|
+
with self._lock:
|
|
202
|
+
run = self._runs.get(run_id)
|
|
203
|
+
if run is None:
|
|
204
|
+
return EventPage(next_seq=since_seq)
|
|
205
|
+
stored = sorted(run.events, key=lambda event: event.seq)
|
|
206
|
+
|
|
207
|
+
oldest = stored[0].seq if stored else None
|
|
208
|
+
# A hole exists when the caller's next expected event has
|
|
209
|
+
# already been evicted.
|
|
210
|
+
truncated = oldest is not None and since_seq + 1 < oldest
|
|
211
|
+
expected = (
|
|
212
|
+
oldest if truncated and oldest is not None else since_seq + 1
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
selected = contiguous_from(stored, expected, limit)
|
|
216
|
+
|
|
217
|
+
return EventPage(
|
|
218
|
+
events=tuple(selected),
|
|
219
|
+
next_seq=selected[-1].seq if selected else since_seq,
|
|
220
|
+
oldest_seq=oldest,
|
|
221
|
+
truncated=truncated,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _detach(flow: FlowSnapshot) -> FlowSnapshot:
|
|
226
|
+
"""Copy a snapshot on its way out.
|
|
227
|
+
|
|
228
|
+
The dataclass is frozen but its ``atoms`` mapping is not, so handing
|
|
229
|
+
out the stored object would let a reader mutate what the datasource
|
|
230
|
+
believes. A monitoring read must never be able to do that.
|
|
231
|
+
"""
|
|
232
|
+
return replace(flow, atoms=dict(flow.atoms))
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
"""Read taskflow's own persistence layer, and write nothing back.
|
|
14
|
+
|
|
15
|
+
This is the datasource that makes an existing deployment observable
|
|
16
|
+
without touching its flow code. Everything it reports was put there by
|
|
17
|
+
taskflow itself: states via the engine, and per-atom progress via
|
|
18
|
+
``storage.set_task_progress()``, which write-throughs to the backend on
|
|
19
|
+
every ``update_progress()`` call.
|
|
20
|
+
|
|
21
|
+
Two limits are inherent rather than incidental, and callers are better
|
|
22
|
+
off knowing them than discovering them:
|
|
23
|
+
|
|
24
|
+
* **No timestamps.** Only the logbook carries ``created_at`` and
|
|
25
|
+
``updated_at``; flow and atom details carry none. Snapshots are
|
|
26
|
+
therefore stamped with our own observation time.
|
|
27
|
+
* **No topology.** Atoms are persisted, edges are not, so nothing here
|
|
28
|
+
can describe the graph. That needs the in-process listener.
|
|
29
|
+
|
|
30
|
+
Listing is a full scan: taskflow's connection API offers no filtering or
|
|
31
|
+
paging, so filters are applied after reading. Put a cache in front of it
|
|
32
|
+
before pointing a busy API at a large logbook.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import contextlib
|
|
38
|
+
import logging
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
from collections.abc import Iterator
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from taskflow import exceptions as tf_exc
|
|
46
|
+
from taskflow.persistence import backends as tf_backends
|
|
47
|
+
from taskflow.persistence import models as tf_models
|
|
48
|
+
|
|
49
|
+
from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
|
|
50
|
+
from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
|
|
51
|
+
from taskflow_meter.datasource.base import DataSource
|
|
52
|
+
from taskflow_meter.datasource.base import EventPage
|
|
53
|
+
from taskflow_meter.datasource.base import FlowPage
|
|
54
|
+
from taskflow_meter.datasource.base import UnknownMarkerError
|
|
55
|
+
from taskflow_meter.models import RETRY
|
|
56
|
+
from taskflow_meter.models import TASK
|
|
57
|
+
from taskflow_meter.models import AtomSnapshot
|
|
58
|
+
from taskflow_meter.models import FlowSnapshot
|
|
59
|
+
|
|
60
|
+
LOG = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
#: taskflow's name for a task detail, as reported by ``atom_detail_type``.
|
|
63
|
+
_TASK_DETAIL = "TASK_DETAIL"
|
|
64
|
+
|
|
65
|
+
#: Where ``storage.set_task_progress`` files what it records.
|
|
66
|
+
_META_PROGRESS = "progress"
|
|
67
|
+
_META_PROGRESS_DETAILS = "progress_details"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PersistenceDataSource(DataSource):
|
|
71
|
+
"""A read-only view of a taskflow persistence backend."""
|
|
72
|
+
|
|
73
|
+
name = "persistence"
|
|
74
|
+
|
|
75
|
+
#: Persistence stores state, not history. Pair this source with a
|
|
76
|
+
#: Poller feeding a writable datasource to get an event stream.
|
|
77
|
+
supports_events = False
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
backend: Any = None,
|
|
82
|
+
*,
|
|
83
|
+
conf: dict[str, Any] | None = None,
|
|
84
|
+
clock: Callable[[], float] = time.time,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Wrap an existing ``backend``, or build one from ``conf``.
|
|
87
|
+
|
|
88
|
+
A backend passed in is borrowed and never closed -- it usually
|
|
89
|
+
belongs to the application being monitored. One built from
|
|
90
|
+
``conf`` is ours, and :meth:`stop` closes it.
|
|
91
|
+
"""
|
|
92
|
+
if (backend is None) == (conf is None):
|
|
93
|
+
msg = "pass exactly one of backend or conf"
|
|
94
|
+
raise ValueError(msg)
|
|
95
|
+
self._backend = backend
|
|
96
|
+
self._conf = conf
|
|
97
|
+
self._owns_backend = backend is None
|
|
98
|
+
self._clock = clock
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
|
|
101
|
+
def start(self) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
if self._backend is None and self._conf is not None:
|
|
104
|
+
self._backend = tf_backends.fetch(self._conf)
|
|
105
|
+
|
|
106
|
+
def stop(self) -> None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
if self._owns_backend and self._backend is not None:
|
|
109
|
+
self._backend.close()
|
|
110
|
+
self._backend = None
|
|
111
|
+
|
|
112
|
+
@contextlib.contextmanager
|
|
113
|
+
def _connection(self) -> Iterator[Any]:
|
|
114
|
+
"""Borrow a connection, closing it however we leave."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
backend = self._backend
|
|
117
|
+
if backend is None:
|
|
118
|
+
self.start()
|
|
119
|
+
with self._lock:
|
|
120
|
+
backend = self._backend
|
|
121
|
+
if backend is None:
|
|
122
|
+
msg = "datasource has no backend"
|
|
123
|
+
raise RuntimeError(msg)
|
|
124
|
+
connection = backend.get_connection()
|
|
125
|
+
try:
|
|
126
|
+
yield connection
|
|
127
|
+
finally:
|
|
128
|
+
with contextlib.suppress(Exception):
|
|
129
|
+
connection.close()
|
|
130
|
+
|
|
131
|
+
# -- reading ---------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def list_flows(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
state: str | None = None,
|
|
137
|
+
book_id: str | None = None,
|
|
138
|
+
limit: int = DEFAULT_FLOW_LIMIT,
|
|
139
|
+
marker: str | None = None,
|
|
140
|
+
) -> FlowPage:
|
|
141
|
+
if limit < 1:
|
|
142
|
+
msg = "limit must be at least 1"
|
|
143
|
+
raise ValueError(msg)
|
|
144
|
+
|
|
145
|
+
flows = [
|
|
146
|
+
flow
|
|
147
|
+
for flow in self._scan()
|
|
148
|
+
if (state is None or flow.state == state)
|
|
149
|
+
and (book_id is None or flow.book_id == book_id)
|
|
150
|
+
]
|
|
151
|
+
# Flow details carry no timestamp of their own, so ordering falls
|
|
152
|
+
# back to the owning book's creation time, then the run id to keep
|
|
153
|
+
# paging stable.
|
|
154
|
+
flows.sort(key=lambda flow: (-flow.observed_at, flow.run_id))
|
|
155
|
+
|
|
156
|
+
start = 0
|
|
157
|
+
if marker is not None:
|
|
158
|
+
positions = {flow.run_id: i for i, flow in enumerate(flows)}
|
|
159
|
+
if marker not in positions:
|
|
160
|
+
msg = f"unknown paging marker: {marker!r}"
|
|
161
|
+
raise UnknownMarkerError(msg)
|
|
162
|
+
start = positions[marker] + 1
|
|
163
|
+
|
|
164
|
+
window = flows[start : start + limit]
|
|
165
|
+
more = len(flows) > start + limit
|
|
166
|
+
return FlowPage(
|
|
167
|
+
items=tuple(window),
|
|
168
|
+
next_marker=window[-1].run_id if more and window else None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def get_flow(self, run_id: str) -> FlowSnapshot | None:
|
|
172
|
+
"""Return one flow.
|
|
173
|
+
|
|
174
|
+
Reads the flow detail directly, then scans the logbooks only to
|
|
175
|
+
attach the owning book's identity -- taskflow's connection API
|
|
176
|
+
offers no way to go from a flow back to its book.
|
|
177
|
+
"""
|
|
178
|
+
with self._connection() as conn:
|
|
179
|
+
try:
|
|
180
|
+
detail = conn.get_flow_details(run_id, lazy=False)
|
|
181
|
+
except tf_exc.NotFound:
|
|
182
|
+
return None
|
|
183
|
+
book_id, book_name, created = self._find_book(conn, run_id)
|
|
184
|
+
return self._flow_snapshot(detail, book_id, book_name, created)
|
|
185
|
+
|
|
186
|
+
def get_atoms(self, run_id: str) -> tuple[AtomSnapshot, ...] | None:
|
|
187
|
+
"""Return a flow's atoms without paying for the book lookup.
|
|
188
|
+
|
|
189
|
+
The existence check is not redundant: ``get_atoms_for_flow``
|
|
190
|
+
answers an unknown flow with an empty list, which would otherwise
|
|
191
|
+
be indistinguishable from a flow that has no atoms yet -- a 404
|
|
192
|
+
reported as an empty collection.
|
|
193
|
+
"""
|
|
194
|
+
with self._connection() as conn:
|
|
195
|
+
try:
|
|
196
|
+
conn.get_flow_details(run_id, lazy=True)
|
|
197
|
+
details = list(conn.get_atoms_for_flow(run_id))
|
|
198
|
+
except tf_exc.NotFound:
|
|
199
|
+
return None
|
|
200
|
+
atoms = [_atom_snapshot(detail) for detail in details]
|
|
201
|
+
return tuple(sorted(atoms, key=lambda atom: atom.name))
|
|
202
|
+
|
|
203
|
+
def events_since(
|
|
204
|
+
self,
|
|
205
|
+
run_id: str, # noqa: ARG002 - part of the DataSource contract
|
|
206
|
+
*,
|
|
207
|
+
since_seq: int = 0,
|
|
208
|
+
limit: int = DEFAULT_EVENT_LIMIT, # noqa: ARG002 - ditto
|
|
209
|
+
) -> EventPage:
|
|
210
|
+
"""Always empty: taskflow persists state, never a history.
|
|
211
|
+
|
|
212
|
+
Reported through :attr:`supports_events` so an API can decline to
|
|
213
|
+
advertise a stream it cannot serve, rather than handing clients an
|
|
214
|
+
empty one that is indistinguishable from silence.
|
|
215
|
+
"""
|
|
216
|
+
return EventPage(next_seq=since_seq)
|
|
217
|
+
|
|
218
|
+
# -- internals -------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _scan(self) -> list[FlowSnapshot]:
|
|
221
|
+
"""Walk every logbook. See the module docstring on cost."""
|
|
222
|
+
snapshots: list[FlowSnapshot] = []
|
|
223
|
+
with self._connection() as conn:
|
|
224
|
+
for book in conn.get_logbooks(lazy=True):
|
|
225
|
+
created = _epoch(book.created_at)
|
|
226
|
+
for detail in conn.get_flows_for_book(book.uuid):
|
|
227
|
+
snapshots.append(
|
|
228
|
+
self._flow_snapshot(
|
|
229
|
+
detail, book.uuid, book.name, created
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return snapshots
|
|
233
|
+
|
|
234
|
+
def _find_book(
|
|
235
|
+
self, conn: Any, run_id: str
|
|
236
|
+
) -> tuple[str | None, str | None, float | None]:
|
|
237
|
+
for book in conn.get_logbooks(lazy=True):
|
|
238
|
+
for detail in conn.get_flows_for_book(book.uuid):
|
|
239
|
+
if detail.uuid == run_id:
|
|
240
|
+
return book.uuid, book.name, _epoch(book.created_at)
|
|
241
|
+
return None, None, None
|
|
242
|
+
|
|
243
|
+
def _flow_snapshot(
|
|
244
|
+
self,
|
|
245
|
+
detail: Any,
|
|
246
|
+
book_id: str | None,
|
|
247
|
+
book_name: str | None,
|
|
248
|
+
book_created_at: float | None,
|
|
249
|
+
) -> FlowSnapshot:
|
|
250
|
+
atoms = {
|
|
251
|
+
atom.name: atom for atom in (_atom_snapshot(ad) for ad in detail)
|
|
252
|
+
}
|
|
253
|
+
meta = dict(detail.meta or {})
|
|
254
|
+
if book_created_at is not None:
|
|
255
|
+
# The only real timestamp taskflow gives us. Kept in meta
|
|
256
|
+
# rather than passed off as an observation time.
|
|
257
|
+
meta.setdefault("book_created_at", book_created_at)
|
|
258
|
+
return FlowSnapshot(
|
|
259
|
+
run_id=detail.uuid,
|
|
260
|
+
name=detail.name,
|
|
261
|
+
state=detail.state,
|
|
262
|
+
book_id=book_id,
|
|
263
|
+
book_name=book_name,
|
|
264
|
+
observed_at=self._clock(),
|
|
265
|
+
meta=meta,
|
|
266
|
+
atoms=atoms,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _atom_snapshot(detail: Any) -> AtomSnapshot:
|
|
271
|
+
meta = dict(detail.meta or {})
|
|
272
|
+
return AtomSnapshot(
|
|
273
|
+
name=detail.name,
|
|
274
|
+
uuid=detail.uuid,
|
|
275
|
+
atom_type=(
|
|
276
|
+
TASK
|
|
277
|
+
if tf_models.atom_detail_type(detail) == _TASK_DETAIL
|
|
278
|
+
else RETRY
|
|
279
|
+
),
|
|
280
|
+
state=detail.state,
|
|
281
|
+
intention=detail.intention,
|
|
282
|
+
progress=float(meta.get(_META_PROGRESS) or 0.0),
|
|
283
|
+
progress_details=meta.get(_META_PROGRESS_DETAILS),
|
|
284
|
+
failure=_failure_dict(detail.failure),
|
|
285
|
+
revert_failure=_failure_dict(getattr(detail, "revert_failure", None)),
|
|
286
|
+
# Carrying results would mean copying arbitrary application data
|
|
287
|
+
# into a monitoring path, so only their presence is reported. A
|
|
288
|
+
# task that returns None is indistinguishable from one that
|
|
289
|
+
# returned nothing, which is a trade we accept.
|
|
290
|
+
has_result=detail.results is not None,
|
|
291
|
+
meta=meta,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _failure_dict(failure: Any) -> dict[str, Any] | None:
|
|
296
|
+
"""Render a taskflow Failure, tolerating anything that is not one."""
|
|
297
|
+
if failure is None:
|
|
298
|
+
return None
|
|
299
|
+
try:
|
|
300
|
+
return dict(failure.to_dict())
|
|
301
|
+
except Exception:
|
|
302
|
+
LOG.warning("could not serialise a failure", exc_info=True)
|
|
303
|
+
return {"unserialisable": repr(failure)}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _epoch(when: Any) -> float | None:
|
|
307
|
+
"""Convert a datetime to a float, or give up quietly."""
|
|
308
|
+
try:
|
|
309
|
+
return float(when.timestamp())
|
|
310
|
+
except (AttributeError, TypeError, ValueError, OSError):
|
|
311
|
+
return None
|
|
@@ -0,0 +1,21 @@
|
|
|
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 datasource with a schema of its own."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from taskflow_meter.datasource.sqlalchemy.models import metadata
|
|
18
|
+
from taskflow_meter.datasource.sqlalchemy.source import SQLADataSource
|
|
19
|
+
from taskflow_meter.datasource.sqlalchemy.source import upgrade
|
|
20
|
+
|
|
21
|
+
__all__ = ["SQLADataSource", "metadata", "upgrade"]
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
"""Alembic environment for the meter's own schema.
|
|
14
|
+
|
|
15
|
+
Driven programmatically by :func:`taskflow_meter.datasource.sqlalchemy
|
|
16
|
+
.upgrade`, so the URL arrives through the config rather than from an
|
|
17
|
+
``alembic.ini`` a deployment would have to carry.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from alembic import context
|
|
25
|
+
from sqlalchemy import engine_from_config
|
|
26
|
+
from sqlalchemy import pool
|
|
27
|
+
|
|
28
|
+
from taskflow_meter.datasource.sqlalchemy.models import metadata
|
|
29
|
+
|
|
30
|
+
config = context.config
|
|
31
|
+
target_metadata = metadata
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_migrations_offline() -> None:
|
|
35
|
+
context.configure(
|
|
36
|
+
url=config.get_main_option("sqlalchemy.url"),
|
|
37
|
+
target_metadata=target_metadata,
|
|
38
|
+
literal_binds=True,
|
|
39
|
+
dialect_opts={"paramstyle": "named"},
|
|
40
|
+
)
|
|
41
|
+
with context.begin_transaction():
|
|
42
|
+
context.run_migrations()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_migrations_online() -> None:
|
|
46
|
+
connectable = config.attributes.get("connection", None)
|
|
47
|
+
if connectable is None:
|
|
48
|
+
connectable = engine_from_config(
|
|
49
|
+
config.get_section(config.config_ini_section, {}),
|
|
50
|
+
prefix="sqlalchemy.",
|
|
51
|
+
poolclass=pool.NullPool,
|
|
52
|
+
)
|
|
53
|
+
with connectable.connect() as connection:
|
|
54
|
+
_run(connection)
|
|
55
|
+
return
|
|
56
|
+
_run(connectable)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run(connection: Any) -> None:
|
|
60
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
61
|
+
with context.begin_transaction():
|
|
62
|
+
context.run_migrations()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if context.is_offline_mode():
|
|
66
|
+
run_migrations_offline()
|
|
67
|
+
else:
|
|
68
|
+
run_migrations_online()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
revision = ${repr(up_revision)}
|
|
15
|
+
down_revision = ${repr(down_revision)}
|
|
16
|
+
branch_labels = ${repr(branch_labels)}
|
|
17
|
+
depends_on = ${repr(depends_on)}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upgrade() -> None:
|
|
21
|
+
${upgrades if upgrades else "pass"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def downgrade() -> None:
|
|
25
|
+
${downgrades if downgrades else "pass"}
|