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
taskflow_meter/diff.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
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
|
+
"""Turn a pair of snapshots into the events that explain the difference.
|
|
14
|
+
|
|
15
|
+
This is how the read-only producer works: poll the flow, compare against
|
|
16
|
+
what was seen last time, and synthesise exactly the events the in-process
|
|
17
|
+
listener would have emitted. One event vocabulary, two producers.
|
|
18
|
+
|
|
19
|
+
What it cannot recover is ordering *within* a pair of snapshots. If two
|
|
20
|
+
atoms changed between polls, we did not see which moved first, so the rules
|
|
21
|
+
below are chosen to be deterministic and defensible rather than to guess:
|
|
22
|
+
|
|
23
|
+
* atoms are visited in name order, so replaying the same pair of snapshots
|
|
24
|
+
always produces the same events in the same order;
|
|
25
|
+
* an atom's state event precedes its progress event, because a state change
|
|
26
|
+
is the coarser fact;
|
|
27
|
+
* the flow's own event comes first when the flow is starting or continuing,
|
|
28
|
+
and last when the flow has reached a finish state -- a flow does not
|
|
29
|
+
finish before its atoms do.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from collections.abc import Iterator
|
|
36
|
+
from dataclasses import replace
|
|
37
|
+
|
|
38
|
+
from taskflow_meter.events import Event
|
|
39
|
+
from taskflow_meter.events import EventKind
|
|
40
|
+
from taskflow_meter.events import SequenceAllocator
|
|
41
|
+
from taskflow_meter.models import AtomSnapshot
|
|
42
|
+
from taskflow_meter.models import FlowSnapshot
|
|
43
|
+
|
|
44
|
+
LOG = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
_UNSTAMPED_SEQ = 0
|
|
47
|
+
_UNSTAMPED_TS = 0.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def diff_flow(
|
|
51
|
+
old: FlowSnapshot | None,
|
|
52
|
+
new: FlowSnapshot,
|
|
53
|
+
*,
|
|
54
|
+
allocator: SequenceAllocator,
|
|
55
|
+
ts: float | None = None,
|
|
56
|
+
) -> list[Event]:
|
|
57
|
+
"""Return the events describing ``old`` -> ``new``.
|
|
58
|
+
|
|
59
|
+
``old`` is ``None`` for a run's first observation, which yields one
|
|
60
|
+
event per known fact so that a client joining late still receives a
|
|
61
|
+
complete picture rather than only future changes.
|
|
62
|
+
|
|
63
|
+
Sequence numbers come from ``allocator`` in emission order. Nothing is
|
|
64
|
+
allocated when there is nothing to report, so a quiet poll costs no
|
|
65
|
+
sequence numbers and a client's ``since_seq`` stays meaningful.
|
|
66
|
+
"""
|
|
67
|
+
if old is not None and old.run_id != new.run_id:
|
|
68
|
+
msg = f"cannot diff across runs: {old.run_id!r} -> {new.run_id!r}"
|
|
69
|
+
raise ValueError(msg)
|
|
70
|
+
|
|
71
|
+
stamp = new.observed_at if ts is None else ts
|
|
72
|
+
|
|
73
|
+
flow_events = list(_flow_events(old, new))
|
|
74
|
+
atom_events = list(_atom_events(old, new))
|
|
75
|
+
|
|
76
|
+
ordered = (
|
|
77
|
+
atom_events + flow_events
|
|
78
|
+
if new.is_finished
|
|
79
|
+
else flow_events + atom_events
|
|
80
|
+
)
|
|
81
|
+
return [
|
|
82
|
+
replace(event, seq=allocator.allocate(new.run_id), ts=stamp)
|
|
83
|
+
for event in ordered
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _flow_events(
|
|
88
|
+
old: FlowSnapshot | None, new: FlowSnapshot
|
|
89
|
+
) -> Iterator[Event]:
|
|
90
|
+
if old is not None and old.state == new.state:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
details: dict[str, object] = {}
|
|
94
|
+
if old is None:
|
|
95
|
+
# First sight of this run: carry the identity that later events
|
|
96
|
+
# will not repeat, so a datasource can reconstruct the flow from
|
|
97
|
+
# the event stream alone.
|
|
98
|
+
details["flow_name"] = new.name
|
|
99
|
+
if new.book_name is not None:
|
|
100
|
+
details["book_name"] = new.book_name
|
|
101
|
+
|
|
102
|
+
yield _new_event(
|
|
103
|
+
new,
|
|
104
|
+
kind=EventKind.FLOW_STATE,
|
|
105
|
+
state=new.state,
|
|
106
|
+
old_state=old.state if old is not None else None,
|
|
107
|
+
details=details,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _atom_events(
|
|
112
|
+
old: FlowSnapshot | None, new: FlowSnapshot
|
|
113
|
+
) -> Iterator[Event]:
|
|
114
|
+
previous = old.atoms if old is not None else {}
|
|
115
|
+
|
|
116
|
+
for name in new.atom_names:
|
|
117
|
+
current = new.atoms[name]
|
|
118
|
+
before = previous.get(name)
|
|
119
|
+
|
|
120
|
+
if before is None or before.state != current.state:
|
|
121
|
+
yield _atom_state_event(new, before, current)
|
|
122
|
+
|
|
123
|
+
if _progress_changed(before, current):
|
|
124
|
+
yield _atom_progress_event(new, current)
|
|
125
|
+
|
|
126
|
+
for name in sorted(set(previous) - set(new.atoms)):
|
|
127
|
+
# Atoms do not leave a flow while it runs. If one vanishes the
|
|
128
|
+
# snapshot source is lying to us; say so rather than emit an event
|
|
129
|
+
# kind that means nothing downstream.
|
|
130
|
+
LOG.warning(
|
|
131
|
+
"atom %r disappeared from run %s between observations",
|
|
132
|
+
name,
|
|
133
|
+
new.run_id,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _progress_changed(
|
|
138
|
+
before: AtomSnapshot | None, current: AtomSnapshot
|
|
139
|
+
) -> bool:
|
|
140
|
+
if before is None:
|
|
141
|
+
# Only worth an event if there is something to say; a brand new
|
|
142
|
+
# atom sitting at 0.0 is already fully described by its state.
|
|
143
|
+
return current.progress != 0.0 or current.progress_details is not None
|
|
144
|
+
return (
|
|
145
|
+
before.progress != current.progress
|
|
146
|
+
or before.progress_details != current.progress_details
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _atom_state_event(
|
|
151
|
+
flow: FlowSnapshot,
|
|
152
|
+
before: AtomSnapshot | None,
|
|
153
|
+
current: AtomSnapshot,
|
|
154
|
+
) -> Event:
|
|
155
|
+
details: dict[str, object] = {}
|
|
156
|
+
if current.failure is not None and (
|
|
157
|
+
before is None or before.failure != current.failure
|
|
158
|
+
):
|
|
159
|
+
details["failure"] = current.failure
|
|
160
|
+
if current.revert_failure is not None and (
|
|
161
|
+
before is None or before.revert_failure != current.revert_failure
|
|
162
|
+
):
|
|
163
|
+
details["revert_failure"] = current.revert_failure
|
|
164
|
+
if current.has_result:
|
|
165
|
+
details["has_result"] = True
|
|
166
|
+
|
|
167
|
+
return _new_event(
|
|
168
|
+
flow,
|
|
169
|
+
kind=EventKind.ATOM_STATE,
|
|
170
|
+
atom=current,
|
|
171
|
+
state=current.state,
|
|
172
|
+
old_state=before.state if before is not None else None,
|
|
173
|
+
progress=current.progress,
|
|
174
|
+
details=details,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _atom_progress_event(flow: FlowSnapshot, current: AtomSnapshot) -> Event:
|
|
179
|
+
details: dict[str, object] = {}
|
|
180
|
+
if current.progress_details is not None:
|
|
181
|
+
details["progress_details"] = current.progress_details
|
|
182
|
+
return _new_event(
|
|
183
|
+
flow,
|
|
184
|
+
kind=EventKind.ATOM_PROGRESS,
|
|
185
|
+
atom=current,
|
|
186
|
+
state=current.state,
|
|
187
|
+
progress=current.progress,
|
|
188
|
+
details=details,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _new_event(
|
|
193
|
+
flow: FlowSnapshot,
|
|
194
|
+
*,
|
|
195
|
+
kind: EventKind,
|
|
196
|
+
atom: AtomSnapshot | None = None,
|
|
197
|
+
state: str | None = None,
|
|
198
|
+
old_state: str | None = None,
|
|
199
|
+
progress: float | None = None,
|
|
200
|
+
details: dict[str, object] | None = None,
|
|
201
|
+
) -> Event:
|
|
202
|
+
"""Build an event with placeholder ``seq``/``ts``.
|
|
203
|
+
|
|
204
|
+
:func:`diff_flow` stamps both once emission order is settled.
|
|
205
|
+
"""
|
|
206
|
+
return Event(
|
|
207
|
+
run_id=flow.run_id,
|
|
208
|
+
seq=_UNSTAMPED_SEQ,
|
|
209
|
+
ts=_UNSTAMPED_TS,
|
|
210
|
+
kind=kind,
|
|
211
|
+
book_id=flow.book_id,
|
|
212
|
+
atom_name=atom.name if atom is not None else None,
|
|
213
|
+
atom_uuid=atom.uuid if atom is not None else None,
|
|
214
|
+
atom_type=atom.atom_type if atom is not None else None,
|
|
215
|
+
state=state,
|
|
216
|
+
old_state=old_state,
|
|
217
|
+
intention=atom.intention if atom is not None else None,
|
|
218
|
+
progress=progress,
|
|
219
|
+
details=dict(details or {}),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = ["diff_flow"]
|
taskflow_meter/events.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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 single event shape every producer emits.
|
|
14
|
+
|
|
15
|
+
The in-process listener and the persistence poller observe a flow in very
|
|
16
|
+
different ways, but both express what they saw as one of these, so the
|
|
17
|
+
datasources, the API and any transport only ever handle one vocabulary.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from dataclasses import asdict
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from dataclasses import field
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
from typing import Any
|
|
29
|
+
from typing import Self
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EventKind(StrEnum):
|
|
33
|
+
"""What an event is reporting."""
|
|
34
|
+
|
|
35
|
+
#: The flow itself moved between states.
|
|
36
|
+
FLOW_STATE = "flow_state"
|
|
37
|
+
#: An atom moved between states, or was seen for the first time.
|
|
38
|
+
ATOM_STATE = "atom_state"
|
|
39
|
+
#: An atom reported progress without necessarily changing state.
|
|
40
|
+
ATOM_PROGRESS = "atom_progress"
|
|
41
|
+
#: The flow's graph, emitted once by the in-process producer. The
|
|
42
|
+
#: poller cannot produce this: taskflow persists atoms but not edges.
|
|
43
|
+
FLOW_STRUCTURE = "flow_structure"
|
|
44
|
+
#: The flow finished and a result or failure summary is available.
|
|
45
|
+
FLOW_RESULT = "flow_result"
|
|
46
|
+
#: Liveness only; carries no state change.
|
|
47
|
+
HEARTBEAT = "heartbeat"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Event:
|
|
52
|
+
"""One observation about one flow run.
|
|
53
|
+
|
|
54
|
+
``seq`` is gap-free and monotonic per ``run_id``, which is what lets a
|
|
55
|
+
client reconnect with ``since_seq`` and know whether it missed
|
|
56
|
+
anything. ``ts`` is observation time -- see :class:`.FlowSnapshot`.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
run_id: str
|
|
60
|
+
seq: int
|
|
61
|
+
ts: float
|
|
62
|
+
kind: EventKind
|
|
63
|
+
book_id: str | None = None
|
|
64
|
+
atom_name: str | None = None
|
|
65
|
+
atom_uuid: str | None = None
|
|
66
|
+
atom_type: str | None = None
|
|
67
|
+
state: str | None = None
|
|
68
|
+
old_state: str | None = None
|
|
69
|
+
intention: str | None = None
|
|
70
|
+
progress: float | None = None
|
|
71
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
"""Render to plain JSON-serialisable types."""
|
|
75
|
+
data = asdict(self)
|
|
76
|
+
data["kind"] = str(self.kind)
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, data: Mapping[str, Any]) -> Self:
|
|
81
|
+
"""Rebuild from :meth:`to_dict` output.
|
|
82
|
+
|
|
83
|
+
Unknown keys are rejected rather than dropped -- a transport
|
|
84
|
+
speaking a newer dialect should fail loudly, not silently discard
|
|
85
|
+
the part we did not understand.
|
|
86
|
+
"""
|
|
87
|
+
payload = dict(data)
|
|
88
|
+
payload["kind"] = EventKind(payload["kind"])
|
|
89
|
+
return cls(**payload)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SequenceAllocator:
|
|
93
|
+
"""Hands out gap-free per-run sequence numbers, starting at 1.
|
|
94
|
+
|
|
95
|
+
Thread-safe by necessity: the parallel engine fires notifier callbacks
|
|
96
|
+
from executor threads, and the poller runs on its own thread.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
__slots__ = ("_counters", "_lock")
|
|
100
|
+
|
|
101
|
+
def __init__(self, counters: Mapping[str, int] | None = None) -> None:
|
|
102
|
+
self._lock = threading.Lock()
|
|
103
|
+
self._counters: dict[str, int] = dict(counters or {})
|
|
104
|
+
|
|
105
|
+
def allocate(self, run_id: str) -> int:
|
|
106
|
+
"""Return the next sequence number for ``run_id``."""
|
|
107
|
+
with self._lock:
|
|
108
|
+
nxt = self._counters.get(run_id, 0) + 1
|
|
109
|
+
self._counters[run_id] = nxt
|
|
110
|
+
return nxt
|
|
111
|
+
|
|
112
|
+
def peek(self, run_id: str) -> int:
|
|
113
|
+
"""Return the last number handed out, or 0 if none has been."""
|
|
114
|
+
with self._lock:
|
|
115
|
+
return self._counters.get(run_id, 0)
|
|
116
|
+
|
|
117
|
+
def resume_from(self, run_id: str, seq: int) -> None:
|
|
118
|
+
"""Continue numbering after ``seq``.
|
|
119
|
+
|
|
120
|
+
A restarted collector calls this with the highest sequence already
|
|
121
|
+
stored, so it does not renumber events a client has already seen.
|
|
122
|
+
"""
|
|
123
|
+
with self._lock:
|
|
124
|
+
self._counters[run_id] = max(self._counters.get(run_id, 0), seq)
|
|
125
|
+
|
|
126
|
+
def forget(self, run_id: str) -> None:
|
|
127
|
+
"""Drop the counter for a run that is finished and expired."""
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._counters.pop(run_id, None)
|
taskflow_meter/fold.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
"""Folding events back into a snapshot.
|
|
14
|
+
|
|
15
|
+
Shared by every writable datasource, so they cannot disagree about what
|
|
16
|
+
an event means. The property this exists to keep true: applying the
|
|
17
|
+
events a diff produced reproduces the snapshots the diff came from --
|
|
18
|
+
whichever store is doing the applying.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from taskflow_meter.events import Event
|
|
27
|
+
from taskflow_meter.events import EventKind
|
|
28
|
+
from taskflow_meter.models import AtomSnapshot
|
|
29
|
+
from taskflow_meter.models import FlowSnapshot
|
|
30
|
+
|
|
31
|
+
LOG = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def flow_from_event(event: Event) -> FlowSnapshot:
|
|
35
|
+
"""Seed a run from the first event seen for it.
|
|
36
|
+
|
|
37
|
+
The diff engine puts the flow's identity on the first event of a
|
|
38
|
+
run, but a client may join mid-stream, so everything here has to
|
|
39
|
+
tolerate absence.
|
|
40
|
+
"""
|
|
41
|
+
details = event.details
|
|
42
|
+
return FlowSnapshot(
|
|
43
|
+
run_id=event.run_id,
|
|
44
|
+
name=str(details.get("flow_name", "")),
|
|
45
|
+
book_id=event.book_id,
|
|
46
|
+
book_name=_optional_str(details.get("book_name")),
|
|
47
|
+
observed_at=event.ts,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fold(flow: FlowSnapshot, event: Event) -> FlowSnapshot:
|
|
52
|
+
"""Return ``flow`` updated by ``event``."""
|
|
53
|
+
from dataclasses import replace
|
|
54
|
+
|
|
55
|
+
flow = replace(flow, observed_at=max(flow.observed_at, event.ts))
|
|
56
|
+
|
|
57
|
+
if event.kind is EventKind.FLOW_STATE:
|
|
58
|
+
return replace(flow, state=event.state)
|
|
59
|
+
|
|
60
|
+
if event.kind in (EventKind.ATOM_STATE, EventKind.ATOM_PROGRESS):
|
|
61
|
+
if event.atom_name is None:
|
|
62
|
+
LOG.warning(
|
|
63
|
+
"ignoring %s event %d for run %s: no atom name",
|
|
64
|
+
event.kind,
|
|
65
|
+
event.seq,
|
|
66
|
+
event.run_id,
|
|
67
|
+
)
|
|
68
|
+
return flow
|
|
69
|
+
atoms = dict(flow.atoms)
|
|
70
|
+
atoms[event.atom_name] = fold_atom(
|
|
71
|
+
atoms.get(event.atom_name), event, event.atom_name
|
|
72
|
+
)
|
|
73
|
+
return replace(flow, atoms=atoms)
|
|
74
|
+
|
|
75
|
+
return flow
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fold_atom(
|
|
79
|
+
atom: AtomSnapshot | None, event: Event, name: str
|
|
80
|
+
) -> AtomSnapshot:
|
|
81
|
+
from dataclasses import replace
|
|
82
|
+
|
|
83
|
+
if atom is None:
|
|
84
|
+
atom = AtomSnapshot(name=name)
|
|
85
|
+
|
|
86
|
+
changes: dict[str, Any] = {}
|
|
87
|
+
if event.atom_uuid is not None:
|
|
88
|
+
changes["uuid"] = event.atom_uuid
|
|
89
|
+
if event.atom_type is not None:
|
|
90
|
+
changes["atom_type"] = event.atom_type
|
|
91
|
+
if event.intention is not None:
|
|
92
|
+
changes["intention"] = event.intention
|
|
93
|
+
if event.progress is not None:
|
|
94
|
+
changes["progress"] = event.progress
|
|
95
|
+
|
|
96
|
+
if event.kind is EventKind.ATOM_STATE:
|
|
97
|
+
changes["state"] = event.state
|
|
98
|
+
failure = event.details.get("failure")
|
|
99
|
+
if failure is not None:
|
|
100
|
+
changes["failure"] = failure
|
|
101
|
+
revert_failure = event.details.get("revert_failure")
|
|
102
|
+
if revert_failure is not None:
|
|
103
|
+
changes["revert_failure"] = revert_failure
|
|
104
|
+
if event.details.get("has_result"):
|
|
105
|
+
changes["has_result"] = True
|
|
106
|
+
|
|
107
|
+
if event.kind is EventKind.ATOM_PROGRESS:
|
|
108
|
+
changes["progress_details"] = event.details.get("progress_details")
|
|
109
|
+
|
|
110
|
+
return replace(atom, **changes)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def contiguous_from(
|
|
114
|
+
events: list[Event], expected: int, limit: int
|
|
115
|
+
) -> list[Event]:
|
|
116
|
+
"""Take the unbroken run of events starting at ``expected``.
|
|
117
|
+
|
|
118
|
+
Concurrent producers do not arrive in sequence order: two threads
|
|
119
|
+
each allocate a number and then hand it on, and the hand-offs can
|
|
120
|
+
invert. Returning event 8 while 7 is still in flight would advance
|
|
121
|
+
a caller past 7 for good, so anything beyond a gap waits.
|
|
122
|
+
"""
|
|
123
|
+
taken: list[Event] = []
|
|
124
|
+
for event in events:
|
|
125
|
+
if event.seq < expected:
|
|
126
|
+
continue
|
|
127
|
+
if event.seq != expected:
|
|
128
|
+
break
|
|
129
|
+
taken.append(event)
|
|
130
|
+
expected += 1
|
|
131
|
+
if len(taken) >= limit:
|
|
132
|
+
break
|
|
133
|
+
return taken
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _optional_str(value: Any) -> str | None:
|
|
137
|
+
return None if value is None else str(value)
|