active_durable 0.5.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +131 -0
- data/LICENSE.txt +21 -0
- data/README.md +630 -0
- data/Rakefile +12 -0
- data/app/controllers/active_durable/application_controller.rb +25 -0
- data/app/controllers/active_durable/executions_controller.rb +59 -0
- data/app/helpers/active_durable/dashboard_helper.rb +163 -0
- data/app/views/active_durable/executions/index.html.erb +90 -0
- data/app/views/active_durable/executions/show.html.erb +197 -0
- data/app/views/layouts/active_durable/application.html.erb +422 -0
- data/config/routes.rb +13 -0
- data/lib/active_durable/configuration.rb +59 -0
- data/lib/active_durable/engine.rb +24 -0
- data/lib/active_durable/errors.rb +61 -0
- data/lib/active_durable/execution.rb +43 -0
- data/lib/active_durable/flow.rb +277 -0
- data/lib/active_durable/flow_parallel.rb +200 -0
- data/lib/active_durable/lease.rb +50 -0
- data/lib/active_durable/notebook.rb +100 -0
- data/lib/active_durable/open_telemetry.rb +94 -0
- data/lib/active_durable/operations.rb +111 -0
- data/lib/active_durable/parallel.rb +54 -0
- data/lib/active_durable/record.rb +13 -0
- data/lib/active_durable/registry.rb +89 -0
- data/lib/active_durable/retry_policy.rb +42 -0
- data/lib/active_durable/run_job.rb +12 -0
- data/lib/active_durable/runner.rb +157 -0
- data/lib/active_durable/serializer.rb +40 -0
- data/lib/active_durable/signal_record.rb +20 -0
- data/lib/active_durable/step.rb +34 -0
- data/lib/active_durable/sweep_job.rb +12 -0
- data/lib/active_durable/sweeper.rb +22 -0
- data/lib/active_durable/testing.rb +118 -0
- data/lib/active_durable/version.rb +5 -0
- data/lib/active_durable.rb +145 -0
- data/lib/generators/active_durable/install/install_generator.rb +27 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
- data/lib/tasks/active_durable.rake +24 -0
- data/sig/active_durable.rbs +4 -0
- metadata +134 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= content_for?(:title) ? "#{yield(:title)} · ActiveDurable" : "ActiveDurable" %></title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style nonce="<%= content_security_policy_nonce %>">
|
|
9
|
+
/* Layout: a fixed rail (statuses, recipes) and a full-width stage. Sagas are drawn as blocks wired
|
|
10
|
+
together; every animation encodes a state (running, waiting, sleeping, failed, undone). */
|
|
11
|
+
:root {
|
|
12
|
+
--paper: #f2f4fa; --surface: #ffffff; --sunken: #eaeef7;
|
|
13
|
+
--ink: #141b33; --soft: #56607e; --line: #d3daea;
|
|
14
|
+
--jade: #12936a; --jade-bg: #daf3e9; --amber: #a86f00; --amber-bg: #fcefcc;
|
|
15
|
+
--cobalt: #2c5fd6; --cobalt-bg: #e1e9fc; --ruby: #cc1f3d; --ruby-bg: #fbe1e6;
|
|
16
|
+
--violet: #6b44c8; --violet-bg: #ece5fb; --slate: #6b7593; --slate-bg: #e6e9f1;
|
|
17
|
+
--shade: rgba(20, 27, 51, .28);
|
|
18
|
+
--f-round: ui-rounded, "SF Pro Rounded", "Hiragino Maru Gothic ProN", "Nunito", "Varela Round", system-ui, sans-serif;
|
|
19
|
+
--f-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
20
|
+
--f-code: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--paper: #0e1424; --surface: #151d33; --sunken: #10172a;
|
|
25
|
+
--ink: #e8ecf8; --soft: #9aa5c4; --line: #26304d;
|
|
26
|
+
--jade: #3dd6a0; --jade-bg: #0f3329; --amber: #f2b641; --amber-bg: #3a2d10;
|
|
27
|
+
--cobalt: #7ea6ff; --cobalt-bg: #182649; --ruby: #ff5c74; --ruby-bg: #3b1522;
|
|
28
|
+
--violet: #b08cff; --violet-bg: #281f48; --slate: #8b95b3; --slate-bg: #1d2540;
|
|
29
|
+
--shade: rgba(0, 0, 0, .45);
|
|
30
|
+
color-scheme: dark;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
.tone-good, .st-completed { --c: var(--jade); --cb: var(--jade-bg); }
|
|
34
|
+
.tone-wait, .st-retrying, .st-waiting, .st-sleeping { --c: var(--amber); --cb: var(--amber-bg); }
|
|
35
|
+
.tone-busy, .st-running, .st-next { --c: var(--cobalt); --cb: var(--cobalt-bg); }
|
|
36
|
+
.tone-bad, .st-failed { --c: var(--ruby); --cb: var(--ruby-bg); }
|
|
37
|
+
.tone-undo, .st-undone { --c: var(--violet); --cb: var(--violet-bg); }
|
|
38
|
+
.tone-muted { --c: var(--slate); --cb: var(--slate-bg); }
|
|
39
|
+
|
|
40
|
+
* { box-sizing: border-box; }
|
|
41
|
+
html, body { min-height: 100%; }
|
|
42
|
+
body {
|
|
43
|
+
margin: 0; color: var(--ink); font: 15px/1.5 var(--f-body);
|
|
44
|
+
background-color: var(--paper);
|
|
45
|
+
}
|
|
46
|
+
a { color: var(--cobalt); text-underline-offset: 3px; }
|
|
47
|
+
:focus-visible { outline: 3px solid var(--cobalt); outline-offset: 2px; border-radius: 6px; }
|
|
48
|
+
h1, h2, h3 { font-family: var(--f-round); margin: 0; line-height: 1.15; text-wrap: balance; }
|
|
49
|
+
h2 { font-size: 1.1rem; }
|
|
50
|
+
p { margin: 0; }
|
|
51
|
+
.muted { color: var(--soft); }
|
|
52
|
+
code, .json, .mono { font-family: var(--f-code); font-size: .82rem; overflow-wrap: anywhere; }
|
|
53
|
+
|
|
54
|
+
/* ---------- shell ---------- */
|
|
55
|
+
.shell { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 100vh; }
|
|
56
|
+
.rail {
|
|
57
|
+
position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 22px 16px;
|
|
58
|
+
display: flex; flex-direction: column; gap: 26px; border-right: 1px solid var(--line);
|
|
59
|
+
background: color-mix(in srgb, var(--surface) 82%, transparent); backdrop-filter: blur(8px);
|
|
60
|
+
}
|
|
61
|
+
.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none;
|
|
62
|
+
font: 800 1.15rem/1 var(--f-round); }
|
|
63
|
+
.logo { display: grid; grid-template-columns: repeat(2, 11px); gap: 3px; }
|
|
64
|
+
.logo i { width: 11px; height: 11px; border-radius: 3px; background: var(--c); box-shadow: 0 2px 0 var(--shade); }
|
|
65
|
+
.logo i:nth-child(1) { --c: var(--jade); } .logo i:nth-child(2) { --c: var(--amber); }
|
|
66
|
+
.logo i:nth-child(3) { --c: var(--cobalt); } .logo i:nth-child(4) { --c: var(--ruby); }
|
|
67
|
+
.brand:hover .logo i { animation: hop .5s ease both; }
|
|
68
|
+
.brand:hover .logo i:nth-child(2) { animation-delay: .06s; } .brand:hover .logo i:nth-child(3) { animation-delay: .12s; }
|
|
69
|
+
.brand:hover .logo i:nth-child(4) { animation-delay: .18s; }
|
|
70
|
+
.rail h2 { font-size: .85rem; color: var(--soft); font-weight: 700; margin-bottom: 8px; }
|
|
71
|
+
.rail nav { display: flex; flex-direction: column; gap: 2px; }
|
|
72
|
+
.rail-link { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 9px; color: var(--ink);
|
|
73
|
+
text-decoration: none; font-weight: 600; }
|
|
74
|
+
.rail-link:hover { background: var(--sunken); }
|
|
75
|
+
.rail-link[aria-current="page"] { background: var(--cb, var(--sunken)); color: var(--c, var(--ink)); }
|
|
76
|
+
.rail-link .n { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--soft); font-weight: 700; }
|
|
77
|
+
.rail-link .chip { width: 12px; height: 12px; border-radius: 3px; background: var(--c, var(--slate)); box-shadow: 0 2px 0 var(--shade); flex: none; }
|
|
78
|
+
.rail-foot { margin-top: auto; color: var(--soft); font-size: .82rem; }
|
|
79
|
+
|
|
80
|
+
.stage { padding: 26px 34px 56px; display: flex; flex-direction: column; gap: 22px; min-width: 0; }
|
|
81
|
+
.flash { padding: 12px 16px; border-radius: 12px; font-weight: 600; animation: rise .35s ease both; }
|
|
82
|
+
.flash.notice { background: var(--jade-bg); color: var(--jade); }
|
|
83
|
+
.flash.alert { background: var(--ruby-bg); color: var(--ruby); }
|
|
84
|
+
|
|
85
|
+
/* ---------- headings and tools ---------- */
|
|
86
|
+
.stage-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 16px 24px; }
|
|
87
|
+
.stage-head h1 { font-size: clamp(1.8rem, 3vw, 2.6rem); font-weight: 800; letter-spacing: -.01em; }
|
|
88
|
+
.lede { color: var(--soft); font-size: 1.02rem; margin-top: 6px; }
|
|
89
|
+
.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
90
|
+
input, select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px;
|
|
91
|
+
background: var(--surface); color: var(--ink); min-width: 0; }
|
|
92
|
+
button, .button { font: inherit; font-weight: 700; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--line);
|
|
93
|
+
background: var(--surface); color: var(--ink); cursor: pointer; text-decoration: none; }
|
|
94
|
+
button:hover { border-color: var(--soft); }
|
|
95
|
+
button.primary { background: var(--cobalt); border-color: var(--cobalt); color: #fff; box-shadow: 0 3px 0 var(--shade); }
|
|
96
|
+
button.danger { background: var(--ruby); border-color: var(--ruby); color: #fff; box-shadow: 0 3px 0 var(--shade); }
|
|
97
|
+
button.primary:active, button.danger:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--shade); }
|
|
98
|
+
form.inline { display: inline-flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0; }
|
|
99
|
+
.live { display: inline-flex; align-items: center; gap: 8px; }
|
|
100
|
+
.live .led { width: 9px; height: 9px; border-radius: 50%; background: var(--slate); }
|
|
101
|
+
.live[aria-pressed="true"] { border-color: var(--jade); color: var(--jade); }
|
|
102
|
+
.live[aria-pressed="true"] .led { background: var(--jade); animation: breathe 1.6s ease-in-out infinite; }
|
|
103
|
+
.live-bar { position: fixed; top: 0; left: 0; height: 3px; background: var(--jade); width: 0; z-index: 10; }
|
|
104
|
+
.live-bar.run { animation: fill var(--cycle, 5s) linear forwards; }
|
|
105
|
+
|
|
106
|
+
/* ---------- the list ---------- */
|
|
107
|
+
.sheet { background: color-mix(in srgb, var(--surface) 92%, transparent); border: 1px solid var(--line); border-radius: 16px;
|
|
108
|
+
overflow-x: auto; }
|
|
109
|
+
table { border-collapse: collapse; width: 100%; font-size: .92rem; }
|
|
110
|
+
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
|
111
|
+
th { color: var(--soft); font-weight: 700; font-size: .82rem; background: var(--sunken); white-space: nowrap; }
|
|
112
|
+
tbody tr:last-child td { border-bottom: 0; }
|
|
113
|
+
tbody tr { transition: background-color .2s; }
|
|
114
|
+
tbody tr:hover { background: var(--sunken); }
|
|
115
|
+
tr.changed { animation: rowflash 1.8s ease; }
|
|
116
|
+
.exec-link { font: 700 .95rem/1.2 var(--f-code); white-space: nowrap; text-decoration: none; color: var(--ink); }
|
|
117
|
+
.exec-link:hover { text-decoration: underline; }
|
|
118
|
+
.recipe { display: block; color: var(--soft); font-size: .82rem; margin-top: 2px; }
|
|
119
|
+
.problem { color: var(--ruby); max-width: 34ch; }
|
|
120
|
+
.when { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
121
|
+
.status { display: inline-flex; align-items: center; gap: 7px; padding: 4px 10px 4px 8px; border-radius: 999px;
|
|
122
|
+
background: var(--cb); color: var(--c); font-weight: 700; font-size: .82rem; white-space: nowrap; }
|
|
123
|
+
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
|
|
124
|
+
.status.tone-busy .dot, .status.tone-bad .dot { animation: breathe 1.4s ease-in-out infinite; }
|
|
125
|
+
.status.tone-wait .dot { animation: breathe 2.6s ease-in-out infinite; }
|
|
126
|
+
.pill { display: inline-block; font-size: .78rem; font-weight: 700; padding: 3px 9px; border-radius: 999px;
|
|
127
|
+
background: var(--sunken); color: var(--soft); white-space: nowrap; }
|
|
128
|
+
.pill-good { background: var(--jade-bg); color: var(--jade); } .pill-bad { background: var(--ruby-bg); color: var(--ruby); }
|
|
129
|
+
.pill-wait { background: var(--amber-bg); color: var(--amber); } .pill-busy { background: var(--cobalt-bg); color: var(--cobalt); }
|
|
130
|
+
.pill-undo { background: var(--violet-bg); color: var(--violet); }
|
|
131
|
+
.pager { display: flex; gap: 16px; }
|
|
132
|
+
.empty { padding: 48px 24px; text-align: center; display: flex; flex-direction: column; gap: 10px; align-items: center; }
|
|
133
|
+
|
|
134
|
+
/* mini track: one small block per step */
|
|
135
|
+
.mini { display: flex; align-items: center; gap: 5px; min-height: 26px; }
|
|
136
|
+
.mini .b { width: 18px; height: 18px; border-radius: 5px; background: var(--cb); border: 2px solid var(--c, var(--slate));
|
|
137
|
+
box-shadow: 0 2px 0 var(--shade); flex: none; animation: pop .35s cubic-bezier(.2, 1.6, .4, 1) both;
|
|
138
|
+
animation-delay: calc(var(--i, 0) * 45ms); }
|
|
139
|
+
.mini .b.st-completed { background: var(--c); }
|
|
140
|
+
.mini .b.k-pivot { transform: rotate(45deg) scale(.86); animation-name: pop-pivot; }
|
|
141
|
+
.mini .b.st-failed { animation: pop .35s both, shake .5s .5s ease 2; }
|
|
142
|
+
.mini .b.st-undone { background: repeating-linear-gradient(135deg, var(--cb) 0 3px, var(--c) 3px 5px); }
|
|
143
|
+
.mini .b.st-next { border-style: dashed; background: transparent; animation: pop .35s both, beat 1.2s ease-in-out infinite; }
|
|
144
|
+
.mini .b.st-retrying, .mini .b.st-sleeping { animation: pop .35s both, beat 2.2s ease-in-out infinite; }
|
|
145
|
+
.mini .b.st-waiting { animation: pop .35s both, ping-small 2s ease-out infinite; }
|
|
146
|
+
.mini .stack { display: flex; flex-direction: column; gap: 2px; }
|
|
147
|
+
.mini .stack .b { width: 18px; height: 7px; border-radius: 2px; border-width: 1.5px; box-shadow: none; }
|
|
148
|
+
.mini .more { font-size: .78rem; color: var(--soft); font-weight: 700; }
|
|
149
|
+
.no-entrance .b, .no-entrance .block, .no-entrance .wire { animation-delay: 0s !important; animation-duration: .01s !important; }
|
|
150
|
+
|
|
151
|
+
/* ---------- one execution ---------- */
|
|
152
|
+
.crumbs { color: var(--ink); font-size: .9rem; font-weight: 600; }
|
|
153
|
+
.hero { display: flex; align-items: center; gap: 22px; padding: 22px 24px; border-radius: 20px; flex-wrap: wrap;
|
|
154
|
+
background: linear-gradient(120deg, var(--cb), color-mix(in srgb, var(--surface) 90%, transparent) 70%);
|
|
155
|
+
border: 1px solid color-mix(in srgb, var(--c) 35%, var(--line)); }
|
|
156
|
+
.hero h1 { font: 800 clamp(1.5rem, 3vw, 2.3rem)/1.1 var(--f-code); overflow-wrap: anywhere; }
|
|
157
|
+
.hero .sub { color: var(--soft); margin-top: 6px; }
|
|
158
|
+
.hero-main { flex: 1 1 320px; min-width: 0; }
|
|
159
|
+
.badge { font: 800 1rem/1 var(--f-round); color: var(--c); padding: 10px 16px; border-radius: 999px;
|
|
160
|
+
background: var(--surface); border: 2px solid var(--c); white-space: nowrap; }
|
|
161
|
+
.beacon { width: 54px; height: 54px; border-radius: 16px; background: var(--c); position: relative; flex: none;
|
|
162
|
+
box-shadow: 0 6px 0 color-mix(in srgb, var(--c) 55%, #000); }
|
|
163
|
+
.hero.tone-bad .beacon { animation: alarm 1.6s ease-in-out infinite; }
|
|
164
|
+
.hero.tone-busy .beacon { animation: beat 1.3s ease-in-out infinite; }
|
|
165
|
+
.hero.tone-wait .beacon { animation: float 3s ease-in-out infinite; }
|
|
166
|
+
.hero.tone-good .beacon { animation: settle .7s cubic-bezier(.2, 1.6, .4, 1) both; }
|
|
167
|
+
.hero.tone-undo .beacon { animation: rewind 2.4s ease-in-out infinite; }
|
|
168
|
+
|
|
169
|
+
.problem-card { display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; border-radius: 14px;
|
|
170
|
+
background: var(--ruby-bg); color: var(--ruby); border: 1px solid color-mix(in srgb, var(--ruby) 35%, transparent); }
|
|
171
|
+
.problem-card strong { font: 700 1.05rem/1.4 var(--f-round); }
|
|
172
|
+
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
|
173
|
+
.actions .note { color: var(--soft); font-size: .88rem; flex-basis: 100%; }
|
|
174
|
+
|
|
175
|
+
/* the track: big blocks wired together */
|
|
176
|
+
.board { background: color-mix(in srgb, var(--surface) 70%, transparent); border: 1px solid var(--line); border-radius: 20px;
|
|
177
|
+
padding: 26px 24px 22px; display: flex; flex-direction: column; gap: 22px; }
|
|
178
|
+
.board-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; }
|
|
179
|
+
.track-scroll { overflow-x: auto; padding: 6px 4px 18px; }
|
|
180
|
+
.track { display: flex; align-items: center; width: 100%; }
|
|
181
|
+
.block { position: relative; flex: 1 1 0; min-width: 158px; max-width: 300px; min-height: 124px; padding: 14px 14px 12px; border-radius: 16px;
|
|
182
|
+
background: var(--cb, var(--slate-bg)); border: 2px solid var(--c, var(--slate)); --c2: color-mix(in srgb, var(--c, var(--slate)) 55%, #000);
|
|
183
|
+
box-shadow: 0 7px 0 var(--c2); display: flex; flex-direction: column; gap: 4px;
|
|
184
|
+
animation: drop .55s cubic-bezier(.2, 1.5, .4, 1) both; animation-delay: calc(var(--i, 0) * 90ms); }
|
|
185
|
+
.block .top { display: flex; justify-content: space-between; align-items: center; font-size: .78rem; color: var(--soft); }
|
|
186
|
+
.block .kind { font-weight: 700; }
|
|
187
|
+
.block .no { font: 800 .9rem/1 var(--f-round); color: var(--c, var(--soft)); }
|
|
188
|
+
.block h3 { font-size: 1.05rem; overflow-wrap: break-word; }
|
|
189
|
+
.block .state { font-weight: 700; color: var(--c, var(--soft)); font-size: .88rem; }
|
|
190
|
+
.block .meta { color: var(--soft); font-size: .8rem; }
|
|
191
|
+
.block .peek { font: .74rem/1.3 var(--f-code); color: var(--soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
192
|
+
.block.st-completed { --cb: color-mix(in srgb, var(--jade-bg) 85%, var(--surface)); }
|
|
193
|
+
.block.k-pivot { border-width: 3px; }
|
|
194
|
+
.block.k-pivot .kind { color: var(--amber); }
|
|
195
|
+
.block.st-failed { animation: drop .55s both, shake .5s .7s ease 2; box-shadow: 0 7px 0 var(--c2), 0 0 0 6px color-mix(in srgb, var(--ruby) 18%, transparent); }
|
|
196
|
+
.block.st-undone { background: repeating-linear-gradient(135deg, var(--violet-bg) 0 10px, color-mix(in srgb, var(--violet) 22%, var(--violet-bg)) 10px 20px); }
|
|
197
|
+
.block.st-undone h3 { text-decoration: line-through; text-decoration-thickness: 2px; }
|
|
198
|
+
.block.st-next { background: transparent; border-style: dashed; box-shadow: none; animation: drop .55s both, beat 1.4s ease-in-out infinite; }
|
|
199
|
+
.block.st-retrying { animation: drop .55s both, beat 2.4s ease-in-out infinite; }
|
|
200
|
+
.block.st-running { animation: drop .55s both, beat 1.2s ease-in-out infinite; }
|
|
201
|
+
.block .radar, .block .radar::after { position: absolute; right: 12px; bottom: 12px; width: 14px; height: 14px; border-radius: 50%;
|
|
202
|
+
border: 2px solid var(--amber); }
|
|
203
|
+
.block .radar::after { content: ""; right: -2px; bottom: -2px; animation: ping 1.8s ease-out infinite; }
|
|
204
|
+
.block .ring { position: absolute; right: 12px; bottom: 12px; width: 22px; height: 22px; border-radius: 50%;
|
|
205
|
+
background: conic-gradient(var(--amber) calc(var(--p, 0) * 1turn), color-mix(in srgb, var(--amber) 20%, transparent) 0);
|
|
206
|
+
-webkit-mask: radial-gradient(circle, transparent 5px, #000 6px); mask: radial-gradient(circle, transparent 5px, #000 6px); }
|
|
207
|
+
.block .spin { position: absolute; right: 12px; bottom: 12px; width: 18px; height: 18px; border-radius: 50%;
|
|
208
|
+
border: 3px solid color-mix(in srgb, var(--amber) 25%, transparent); border-top-color: var(--amber);
|
|
209
|
+
animation: spin 1.1s linear infinite; }
|
|
210
|
+
.block.k-parallel { flex-grow: 1.4; min-width: 200px; }
|
|
211
|
+
.branches { display: flex; flex-direction: column; gap: 5px; margin-top: 4px; }
|
|
212
|
+
.branch { display: flex; align-items: center; gap: 8px; font-size: .82rem; font-weight: 600; }
|
|
213
|
+
.branch i { width: 12px; height: 12px; border-radius: 3px; border: 2px solid var(--c, var(--slate)); background: var(--cb); flex: none; }
|
|
214
|
+
.branch i.st-completed { background: var(--c); }
|
|
215
|
+
.branch i.st-retrying, .branch i.st-running { animation: beat 1.4s ease-in-out infinite; }
|
|
216
|
+
|
|
217
|
+
.wire { flex: 0 1 46px; min-width: 22px; height: 6px; border-radius: 3px; position: relative;
|
|
218
|
+
background: repeating-linear-gradient(90deg, var(--line) 0 9px, transparent 9px 15px);
|
|
219
|
+
animation: grow .4s ease both; animation-delay: calc(var(--i, 0) * 90ms + 60ms); transform-origin: left; }
|
|
220
|
+
.wire.lit { background: repeating-linear-gradient(90deg, var(--jade) 0 9px, transparent 9px 15px); background-size: 30px 6px;
|
|
221
|
+
animation: grow .4s ease both, flow .9s linear infinite; animation-delay: calc(var(--i, 0) * 90ms + 60ms), 0s; }
|
|
222
|
+
.wire.lit.to-active { background-image: repeating-linear-gradient(90deg, var(--cobalt) 0 9px, transparent 9px 15px); }
|
|
223
|
+
.wire.back { background: repeating-linear-gradient(90deg, var(--violet) 0 9px, transparent 9px 15px); background-size: 30px 6px;
|
|
224
|
+
animation: grow .4s ease both, flow .9s linear infinite reverse; animation-delay: calc(var(--i, 0) * 90ms + 60ms), 0s; }
|
|
225
|
+
.gate { flex: none; align-self: stretch; width: 64px; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
226
|
+
gap: 6px; position: relative; animation: rise .5s ease both; animation-delay: calc(var(--i, 0) * 90ms); }
|
|
227
|
+
.gate::before { content: ""; width: 4px; flex: 1; min-height: 70px; border-radius: 2px;
|
|
228
|
+
background: repeating-linear-gradient(180deg, var(--amber) 0 8px, transparent 8px 13px); }
|
|
229
|
+
.gate span { font: 700 .72rem/1.2 var(--f-round); color: var(--amber); text-align: center; }
|
|
230
|
+
|
|
231
|
+
.undo-lane { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
232
|
+
.undo-lane .label { color: var(--violet); font-weight: 700; margin-right: 4px; }
|
|
233
|
+
.undo-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 10px; font-weight: 700; font-size: .85rem;
|
|
234
|
+
color: var(--violet); background: repeating-linear-gradient(135deg, var(--violet-bg) 0 8px, color-mix(in srgb, var(--violet) 18%, var(--violet-bg)) 8px 16px);
|
|
235
|
+
border: 2px solid var(--violet); animation: slide-back .5s ease both; animation-delay: calc(var(--i, 0) * 120ms + .4s); }
|
|
236
|
+
.undo-chip.failed { color: var(--ruby); border-color: var(--ruby); background: var(--ruby-bg); }
|
|
237
|
+
.undo-lane .arrow { color: var(--violet); font-weight: 800; }
|
|
238
|
+
|
|
239
|
+
.details { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); gap: 22px; align-items: start; }
|
|
240
|
+
.panel { background: color-mix(in srgb, var(--surface) 92%, transparent); border: 1px solid var(--line); border-radius: 16px;
|
|
241
|
+
padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
|
242
|
+
.side { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
|
|
243
|
+
.panel table { font-size: .86rem; }
|
|
244
|
+
.panel td, .panel th { padding: 9px 10px; vertical-align: top; }
|
|
245
|
+
.json-block { font: .8rem/1.5 var(--f-code); background: var(--sunken); padding: 12px; border-radius: 10px; margin: 0; overflow-x: auto; }
|
|
246
|
+
.k { font-size: .76rem; color: var(--soft); font-weight: 700; margin-left: 4px; }
|
|
247
|
+
code.ticket { white-space: nowrap; overflow-wrap: normal; }
|
|
248
|
+
td.result { min-width: 20ch; }
|
|
249
|
+
.k-pivot-word { color: var(--amber); }
|
|
250
|
+
.error { color: var(--ruby); }
|
|
251
|
+
dl.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 14px; margin: 0; }
|
|
252
|
+
dl.facts dt { color: var(--soft); } dl.facts dd { margin: 0; min-width: 0; }
|
|
253
|
+
|
|
254
|
+
/* ---------- motion ---------- */
|
|
255
|
+
@keyframes drop { from { opacity: 0; transform: translateY(-26px) scale(.92); } to { opacity: 1; transform: none; } }
|
|
256
|
+
@keyframes pop { from { opacity: 0; transform: scale(.3); } to { opacity: 1; transform: none; } }
|
|
257
|
+
@keyframes pop-pivot { from { opacity: 0; transform: rotate(45deg) scale(.2); } to { opacity: 1; transform: rotate(45deg) scale(.86); } }
|
|
258
|
+
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
|
259
|
+
@keyframes flow { from { background-position: 0 0; } to { background-position: 30px 0; } }
|
|
260
|
+
@keyframes beat { 0%, 100% { filter: none; } 50% { filter: brightness(1.25) drop-shadow(0 0 8px var(--c)); } }
|
|
261
|
+
@keyframes breathe { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.7); } }
|
|
262
|
+
@keyframes ping { from { transform: scale(1); opacity: .9; } to { transform: scale(3.2); opacity: 0; } }
|
|
263
|
+
@keyframes ping-small { 0% { box-shadow: 0 2px 0 var(--shade), 0 0 0 0 color-mix(in srgb, var(--amber) 60%, transparent); }
|
|
264
|
+
100% { box-shadow: 0 2px 0 var(--shade), 0 0 0 9px transparent; } }
|
|
265
|
+
@keyframes shake { 0%, 100% { translate: 0; } 25% { translate: -4px; } 75% { translate: 4px; } }
|
|
266
|
+
@keyframes spin { to { transform: rotate(1turn); } }
|
|
267
|
+
@keyframes alarm { 0%, 100% { box-shadow: 0 6px 0 color-mix(in srgb, var(--c) 55%, #000), 0 0 0 0 color-mix(in srgb, var(--c) 45%, transparent); }
|
|
268
|
+
60% { box-shadow: 0 6px 0 color-mix(in srgb, var(--c) 55%, #000), 0 0 0 16px transparent; } }
|
|
269
|
+
@keyframes float { 0%, 100% { translate: 0 0; } 50% { translate: 0 -6px; } }
|
|
270
|
+
@keyframes settle { from { transform: translateY(-30px) rotate(-12deg); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
271
|
+
@keyframes rewind { 0%, 100% { rotate: 0deg; } 40% { rotate: -14deg; } 60% { rotate: -14deg; } }
|
|
272
|
+
@keyframes hop { 50% { transform: translateY(-5px); } }
|
|
273
|
+
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
274
|
+
@keyframes slide-back { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
|
|
275
|
+
@keyframes fill { from { width: 0; } to { width: 100%; } }
|
|
276
|
+
@keyframes rowflash { 0% { background: color-mix(in srgb, var(--cobalt) 30%, transparent); } 100% { background: transparent; } }
|
|
277
|
+
|
|
278
|
+
@media (max-width: 1100px) { .details { grid-template-columns: minmax(0, 1fr); } }
|
|
279
|
+
@media (max-width: 860px) {
|
|
280
|
+
.shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
|
|
281
|
+
.rail { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: flex-start; align-content: flex-start; gap: 14px 22px;
|
|
282
|
+
border-right: 0; border-bottom: 1px solid var(--line); padding: 14px 16px; }
|
|
283
|
+
.rail nav { flex-direction: row; flex-wrap: wrap; }
|
|
284
|
+
.rail-foot { display: none; }
|
|
285
|
+
.stage { padding: 20px 16px 40px; }
|
|
286
|
+
}
|
|
287
|
+
@media (prefers-reduced-motion: reduce) {
|
|
288
|
+
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
289
|
+
}
|
|
290
|
+
</style>
|
|
291
|
+
</head>
|
|
292
|
+
<body>
|
|
293
|
+
<div class="live-bar" data-live-bar></div>
|
|
294
|
+
<div class="shell">
|
|
295
|
+
<aside class="rail">
|
|
296
|
+
<%= link_to root_path, class: "brand" do %>
|
|
297
|
+
<span class="logo" aria-hidden="true"><i></i><i></i><i></i><i></i></span>ActiveDurable
|
|
298
|
+
<% end %>
|
|
299
|
+
<div data-live-rail>
|
|
300
|
+
<h2>Executions</h2>
|
|
301
|
+
<nav aria-label="Executions by status">
|
|
302
|
+
<%= link_to executions_path, class: "rail-link", "aria-current": ("page" if controller_name == "executions" && action_name == "index" && params[:status].blank?) do %>
|
|
303
|
+
<span class="chip" aria-hidden="true"></span>All<span class="n"><%= status_counts.values.sum %></span>
|
|
304
|
+
<% end %>
|
|
305
|
+
<% ActiveDurable::Execution::STATUSES.each do |status| %>
|
|
306
|
+
<% next unless status_counts[status] %>
|
|
307
|
+
<%= link_to executions_path(status: status), class: "rail-link tone-#{ActiveDurable::DashboardHelper::STATUS_TONES[status]}",
|
|
308
|
+
"aria-current": ("page" if params[:status] == status) do %>
|
|
309
|
+
<span class="chip" aria-hidden="true"></span><%= ActiveDurable::DashboardHelper::STATUS_WORDS[status] %><span class="n"><%= status_counts[status] %></span>
|
|
310
|
+
<% end %>
|
|
311
|
+
<% end %>
|
|
312
|
+
</nav>
|
|
313
|
+
</div>
|
|
314
|
+
<% if recipe_names.any? %>
|
|
315
|
+
<div>
|
|
316
|
+
<h2>Recipes</h2>
|
|
317
|
+
<nav aria-label="Executions by recipe">
|
|
318
|
+
<% recipe_names.each do |recipe| %>
|
|
319
|
+
<%= link_to recipe, executions_path(recipe: recipe), class: "rail-link", "aria-current": ("page" if params[:recipe] == recipe) %>
|
|
320
|
+
<% end %>
|
|
321
|
+
</nav>
|
|
322
|
+
</div>
|
|
323
|
+
<% end %>
|
|
324
|
+
<p class="rail-foot">Version <%= ActiveDurable::VERSION %></p>
|
|
325
|
+
</aside>
|
|
326
|
+
<main class="stage">
|
|
327
|
+
<% flash.each do |type, message| %>
|
|
328
|
+
<p class="flash <%= type %>" role="status"><%= message %></p>
|
|
329
|
+
<% end %>
|
|
330
|
+
<%= yield %>
|
|
331
|
+
</main>
|
|
332
|
+
</div>
|
|
333
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
334
|
+
(function () {
|
|
335
|
+
var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
336
|
+
|
|
337
|
+
function span(seconds) {
|
|
338
|
+
var s = Math.abs(Math.round(seconds));
|
|
339
|
+
if (s < 60) return s + " s";
|
|
340
|
+
var m = Math.floor(s / 60);
|
|
341
|
+
if (m < 60) return m + " min";
|
|
342
|
+
var h = Math.floor(m / 60);
|
|
343
|
+
if (h < 24) return h + " h " + (m % 60) + " min";
|
|
344
|
+
return Math.floor(h / 24) + " d " + (h % 24) + " h";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function tick() {
|
|
348
|
+
var now = Date.now();
|
|
349
|
+
document.querySelectorAll("time[data-relative]").forEach(function (el) {
|
|
350
|
+
var at = Date.parse(el.getAttribute("datetime"));
|
|
351
|
+
if (isNaN(at)) return;
|
|
352
|
+
var diff = (at - now) / 1000;
|
|
353
|
+
el.textContent = Math.abs(diff) < 2 ? "now" : diff > 0 ? "in " + span(diff) : span(diff) + " ago";
|
|
354
|
+
});
|
|
355
|
+
document.querySelectorAll("[data-ring-from]").forEach(function (el) {
|
|
356
|
+
var from = Date.parse(el.dataset.ringFrom), until = Date.parse(el.dataset.ringUntil);
|
|
357
|
+
if (isNaN(from) || isNaN(until) || until <= from) return;
|
|
358
|
+
el.style.setProperty("--p", Math.min(1, Math.max(0, (now - from) / (until - from))).toFixed(3));
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
tick();
|
|
362
|
+
setInterval(tick, 1000);
|
|
363
|
+
|
|
364
|
+
document.addEventListener("submit", function (event) {
|
|
365
|
+
var message = event.target.getAttribute("data-confirm");
|
|
366
|
+
if (message && !window.confirm(message)) event.preventDefault();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Live updates on the list: re-fetch the page every few seconds and highlight rows that changed.
|
|
370
|
+
var toggle = document.querySelector("[data-live-toggle]");
|
|
371
|
+
if (!toggle) return;
|
|
372
|
+
var bar = document.querySelector("[data-live-bar]");
|
|
373
|
+
var cycle = 5000, timer = null;
|
|
374
|
+
|
|
375
|
+
function statuses() {
|
|
376
|
+
var map = {};
|
|
377
|
+
document.querySelectorAll("tr[data-id]").forEach(function (row) { map[row.dataset.id] = row.dataset.status; });
|
|
378
|
+
return map;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function restartBar() {
|
|
382
|
+
if (!bar || reduced || toggle.getAttribute("aria-pressed") !== "true") return;
|
|
383
|
+
bar.classList.remove("run"); void bar.offsetWidth; bar.style.setProperty("--cycle", cycle + "ms"); bar.classList.add("run");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function refresh() {
|
|
387
|
+
var before = statuses();
|
|
388
|
+
fetch(window.location.href, { headers: { "Accept": "text/html" }, credentials: "same-origin" })
|
|
389
|
+
.then(function (response) { return response.ok ? response.text() : null; })
|
|
390
|
+
.then(function (html) {
|
|
391
|
+
if (!html) return;
|
|
392
|
+
var doc = new DOMParser().parseFromString(html, "text/html");
|
|
393
|
+
["[data-live-region]", "[data-live-rail]", "[data-live-summary]"].forEach(function (selector) {
|
|
394
|
+
var fresh = doc.querySelector(selector), current = document.querySelector(selector);
|
|
395
|
+
if (fresh && current) { fresh.classList.add("no-entrance"); current.replaceWith(fresh); }
|
|
396
|
+
});
|
|
397
|
+
document.querySelectorAll("tr[data-id]").forEach(function (row) {
|
|
398
|
+
if (before[row.dataset.id] !== row.dataset.status) row.classList.add("changed");
|
|
399
|
+
});
|
|
400
|
+
tick();
|
|
401
|
+
})
|
|
402
|
+
.catch(function () {})
|
|
403
|
+
.finally(restartBar);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function setLive(on) {
|
|
407
|
+
toggle.setAttribute("aria-pressed", on ? "true" : "false");
|
|
408
|
+
toggle.querySelector("[data-live-label]").textContent = on ? "Live" : "Paused";
|
|
409
|
+
clearInterval(timer); timer = null;
|
|
410
|
+
if (bar) bar.classList.remove("run");
|
|
411
|
+
if (on) { restartBar(); timer = setInterval(refresh, cycle); }
|
|
412
|
+
try { localStorage.setItem("active_durable_live", on ? "1" : "0"); } catch (e) {}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
toggle.addEventListener("click", function () { setLive(toggle.getAttribute("aria-pressed") !== "true"); });
|
|
416
|
+
var saved = null;
|
|
417
|
+
try { saved = localStorage.getItem("active_durable_live"); } catch (e) {}
|
|
418
|
+
setLive(saved === "1");
|
|
419
|
+
})();
|
|
420
|
+
</script>
|
|
421
|
+
</body>
|
|
422
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ActiveDurable::Engine.routes.draw do
|
|
4
|
+
root "executions#index"
|
|
5
|
+
|
|
6
|
+
resources :executions, only: %i[index show], constraints: { id: %r{[^/]+} } do
|
|
7
|
+
member do
|
|
8
|
+
post :retry, action: :retry_now
|
|
9
|
+
post :compensate
|
|
10
|
+
post :rerun
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Settings for the whole gem. Change them in an initializer with ActiveDurable.configure.
|
|
5
|
+
class Configuration
|
|
6
|
+
# How long a worker owns an execution without writing to it. Must be longer than your slowest step:
|
|
7
|
+
# every notebook write renews it.
|
|
8
|
+
attr_accessor :lease_duration
|
|
9
|
+
|
|
10
|
+
# Active Job queue for ActiveDurable::RunJob and ActiveDurable::SweepJob.
|
|
11
|
+
attr_accessor :queue_name
|
|
12
|
+
|
|
13
|
+
# Attempts for a step before the point of no return (then the saga compensates).
|
|
14
|
+
attr_accessor :step_attempts
|
|
15
|
+
|
|
16
|
+
# Attempts for a step after the point of no return (then the execution is blocked for a human).
|
|
17
|
+
attr_accessor :after_pivot_attempts
|
|
18
|
+
|
|
19
|
+
# Attempts for an undo before the execution is blocked.
|
|
20
|
+
attr_accessor :undo_attempts
|
|
21
|
+
|
|
22
|
+
# Seconds to wait before retry number n (1-based). A Proc, an Array of seconds or a fixed number.
|
|
23
|
+
attr_accessor :backoff
|
|
24
|
+
|
|
25
|
+
# The sweeper ignores executions touched more recently than this, to leave room for their own job.
|
|
26
|
+
attr_accessor :sweep_grace
|
|
27
|
+
|
|
28
|
+
# Maximum threads used by flow.parallel. Your connection pool needs at least this many + 1 connections.
|
|
29
|
+
attr_accessor :parallel_concurrency
|
|
30
|
+
|
|
31
|
+
# Returns the current time. Tests can replace it.
|
|
32
|
+
attr_accessor :clock
|
|
33
|
+
|
|
34
|
+
# Who may open the dashboard: ->(controller) { ... } returning true or false. The controller gives you
|
|
35
|
+
# request, session and authenticate_or_request_with_http_basic. Without it, the dashboard is open in
|
|
36
|
+
# development and test only: closed in production, staging and any other environment.
|
|
37
|
+
attr_accessor :dashboard_authorize
|
|
38
|
+
|
|
39
|
+
attr_writer :logger
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@lease_duration = 300
|
|
43
|
+
@queue_name = :default
|
|
44
|
+
@step_attempts = 3
|
|
45
|
+
@after_pivot_attempts = 25
|
|
46
|
+
@undo_attempts = 10
|
|
47
|
+
@backoff = ->(attempt) { [2**attempt, 3600].min }
|
|
48
|
+
@sweep_grace = 60
|
|
49
|
+
@parallel_concurrency = 4
|
|
50
|
+
@clock = -> { Time.current }
|
|
51
|
+
@dashboard_authorize = nil
|
|
52
|
+
@logger = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def logger
|
|
56
|
+
@logger || (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || ActiveSupport::Logger.new(nil)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# The dashboard and the rake tasks. Mount it in config/routes.rb:
|
|
5
|
+
#
|
|
6
|
+
# mount ActiveDurable::Engine => "/durable"
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace ActiveDurable
|
|
9
|
+
|
|
10
|
+
# rails new --api apps drop cookies, sessions and flash. The dashboard needs them for its forms
|
|
11
|
+
# (CSRF protection), so it brings its own, only inside its own middleware stack.
|
|
12
|
+
initializer "active_durable.api_only_middleware" do |app|
|
|
13
|
+
if app.config.api_only
|
|
14
|
+
middleware.use ActionDispatch::Cookies
|
|
15
|
+
middleware.use ActionDispatch::Session::CookieStore, key: "_active_durable_session", same_site: :strict
|
|
16
|
+
middleware.use ActionDispatch::Flash
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
rake_tasks do
|
|
21
|
+
load File.expand_path("../tasks/active_durable.rake", __dir__)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Error classes. Everything a recipe may want to rescue inherits from ActiveDurable::Error.
|
|
4
|
+
module ActiveDurable
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a recipe name (or version) has not been defined.
|
|
8
|
+
class UnknownRecipe < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a recipe is written in a way the engine cannot run safely.
|
|
11
|
+
class InvalidRecipe < Error; end
|
|
12
|
+
|
|
13
|
+
# Two steps in the same recipe share a name, so their notebook entries would collide.
|
|
14
|
+
class DuplicateStepName < InvalidRecipe; end
|
|
15
|
+
|
|
16
|
+
# The recipe no longer matches what the notebook recorded for an execution in flight.
|
|
17
|
+
class RecipeChanged < Error; end
|
|
18
|
+
|
|
19
|
+
# A step returned something that cannot be stored in the notebook as JSON.
|
|
20
|
+
class NotSerializable < Error; end
|
|
21
|
+
|
|
22
|
+
# Raise it inside a step to reject the work for a business reason: no retries, straight to compensation.
|
|
23
|
+
class Abort < Error; end
|
|
24
|
+
|
|
25
|
+
# A wait_for reached its timeout before the signal arrived.
|
|
26
|
+
class WaitTimeout < Error; end
|
|
27
|
+
|
|
28
|
+
# A step ran out of attempts (or was aborted). Rescue it in a recipe to try an alternative path.
|
|
29
|
+
class StepFailed < Error
|
|
30
|
+
attr_reader :step_name
|
|
31
|
+
|
|
32
|
+
def initialize(step_name, error = nil)
|
|
33
|
+
@step_name = step_name
|
|
34
|
+
detail = error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
|
|
35
|
+
super("step :#{step_name} failed#{" (#{detail})" unless detail.empty?}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An undo kept failing after all its attempts. The execution is blocked for a human to review.
|
|
40
|
+
class UndoFailed < Error; end
|
|
41
|
+
|
|
42
|
+
# Control flow signals. They inherit from Exception on purpose: a `rescue => e` inside
|
|
43
|
+
# user code must not swallow them, otherwise a lost lease could keep writing.
|
|
44
|
+
class ControlFlow < Exception; end # rubocop:disable Lint/InheritException
|
|
45
|
+
|
|
46
|
+
# Another worker took over this execution (our lease expired). Stop without writing anything else.
|
|
47
|
+
class LeaseLost < ControlFlow; end
|
|
48
|
+
|
|
49
|
+
# While compensating, replay reached a step that never completed: stop moving forward.
|
|
50
|
+
class StopForward < ControlFlow; end
|
|
51
|
+
|
|
52
|
+
# Serializes an exception for the notebook and the dashboard.
|
|
53
|
+
def self.dump_error(error, step: nil)
|
|
54
|
+
{
|
|
55
|
+
"class" => error.class.name,
|
|
56
|
+
"message" => error.message.to_s[0, 2000],
|
|
57
|
+
"step" => step,
|
|
58
|
+
"at" => now.utc.iso8601(6)
|
|
59
|
+
}.compact
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# One saga run: which recipe, with what input, and where it is.
|
|
5
|
+
class Execution < Record
|
|
6
|
+
self.table_name = "durable_executions"
|
|
7
|
+
|
|
8
|
+
ACTIVE = %w[pending running sleeping waiting].freeze
|
|
9
|
+
TERMINAL = %w[completed compensated blocked superseded].freeze
|
|
10
|
+
STATUSES = (ACTIVE + TERMINAL).freeze
|
|
11
|
+
|
|
12
|
+
attribute :input, JSON_TYPE, default: -> { {} }
|
|
13
|
+
attribute :output, JSON_TYPE
|
|
14
|
+
attribute :error, JSON_TYPE
|
|
15
|
+
|
|
16
|
+
has_many :steps, -> { order(:id) }, class_name: "ActiveDurable::Step",
|
|
17
|
+
foreign_key: :execution_id, inverse_of: :execution, dependent: :delete_all
|
|
18
|
+
has_many :signals, -> { order(:id) }, class_name: "ActiveDurable::SignalRecord",
|
|
19
|
+
foreign_key: :execution_id, inverse_of: :execution, dependent: :delete_all
|
|
20
|
+
|
|
21
|
+
validates :status, inclusion: { in: STATUSES }
|
|
22
|
+
|
|
23
|
+
scope :active, -> { where(status: ACTIVE) }
|
|
24
|
+
scope :terminal, -> { where(status: TERMINAL) }
|
|
25
|
+
|
|
26
|
+
# The buzón: the job is enqueued only after the row is committed. If the process dies in between,
|
|
27
|
+
# the sweeper finds the pending row and enqueues it.
|
|
28
|
+
after_create_commit { ActiveDurable.enqueue(id) }
|
|
29
|
+
|
|
30
|
+
def active?
|
|
31
|
+
ACTIVE.include?(status)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def terminal?
|
|
35
|
+
TERMINAL.include?(status)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The forward steps in recipe order (undo entries excluded).
|
|
39
|
+
def notebook
|
|
40
|
+
steps.where.not(kind: "undo").reorder(:position).to_a
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|