Skip to content

Commit 9f59e58

Browse files
authored
Merge pull request #3 from nsumbadze/feat/retry-with-parameters
Feat/retry with parameters
2 parents 366b775 + 5031969 commit 9f59e58

21 files changed

Lines changed: 1704 additions & 78 deletions

README.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Under a row of queue KPIs, the workspace is organised into four areas:
2626

2727
The active workspace, graph/table mode, time window, queue filter, and selected node are reflected in the query string, so operational views can be shared directly.
2828

29-
To explore without a live queue, run `composer serve:demo`. This boots the workbench application with generated demo data.
29+
To explore without a live queue, run `composer serve:demo`. This boots the workbench application with generated demo data and seeds a handful of failed jobs you can open and retry.
3030

3131
### Configuration
3232

@@ -58,7 +58,7 @@ Live-flow behaviour is configured via `config/horizonxflow.php`:
5858
### Abilities
5959

6060
- `viewHorizon` — required to enter the dashboard (existing Horizon gate).
61-
- `controlHorizon` — required for mutation endpoints (`POST /jobs/retry/{id}`, `POST /masters/{action}`, `POST /supervisors/{name}/{action}`). When the gate is undefined, mutations are only allowed in `local` and `testing` environments; everywhere else, define the gate in `HorizonApplicationServiceProvider::gate()` to enable destructive actions for a trusted subset of users.
61+
- `controlHorizon` — required for mutation endpoints (`POST /jobs/retry/{id}`, `POST /masters/{action}`, `POST /supervisors/{name}/{action}`) and for `GET /jobs/failed/{id}/parameters`, which backs retrying with edited parameters. When the gate is undefined, mutations are only allowed in `local` and `testing` environments; everywhere else, define the gate in `HorizonApplicationServiceProvider::gate()` to enable destructive actions for a trusted subset of users.
6262

6363
### Environment Variables
6464

@@ -70,6 +70,45 @@ Live-flow behaviour is configured via `config/horizonxflow.php`:
7070
- `HORIZONXFLOW_DISCOVER_DATABASE_QUEUES` — overrides `flow.database.discover_connections`.
7171
- `QUEUE_FAILED_TABLE` — overrides `flow.database.failed_table`.
7272

73+
## Retry With Parameters
74+
75+
A failed job usually fails because of what it was handed: a wrong path, a batch size that was too large, a flag left on. Horizon can only push that same job back onto the queue unchanged, so the normal fix is a tinker session or a one-off command. HorizonXFlow lets you change the arguments and retry from the dashboard instead.
76+
77+
Open a failed job and press **Edit Parameters**. HorizonXFlow reads the job class constructor and lists every parameter it accepts, prefilled with the values the failed job was queued with:
78+
79+
<p align="center">
80+
<img src="art/retry-parameters.png" alt="Editing a failed job's parameters before retrying it">
81+
</p>
82+
83+
Change what you need and press **Retry With Parameters**. The job is queued as a normal retry, so it still shows up under the original job's retry history.
84+
85+
What you can edit:
86+
87+
- `string`, `int`, `float`, `bool`, `array` and `iterable` parameters, plus untyped ones holding those values. Arrays are edited as JSON.
88+
- Nullable parameters get a **Send as null** toggle.
89+
- Parameters that were never passed still appear, prefilled with their declared default.
90+
91+
What you cannot edit, and why the panel says so next to each one:
92+
93+
- Objects and Eloquent models. They are shown read-only rather than hidden, so you can still see what the job was carrying.
94+
- Queued closures, and jobs whose class no longer exists in the application.
95+
96+
Values are cast to the parameter's declared type before the job is queued (`"9"` becomes `9` for an `int`). Anything that does not fit is rejected with a `422` and the reason, and nothing is queued. Jobs implementing `ShouldBeEncrypted` are decrypted for inspection and re-encrypted on the way out.
97+
98+
Editing parameters is gated by `controlHorizon`, the same ability an ordinary retry needs. Both the read and the retry go through it:
99+
100+
| Path | Returns |
101+
| ---- | ------- |
102+
| `GET /horizon/api/jobs/failed/{id}/parameters` | The job's constructor parameters, their current values, and whether each one may be edited. |
103+
| `POST /horizon/api/jobs/retry/{id}` | Retries the job. Accepts an optional `parameters` object of overrides. |
104+
105+
To try it locally, `composer serve:demo` seeds three failed demo jobs. You can also seed or remove them directly:
106+
107+
```bash
108+
php artisan horizonxflow:demo-jobs
109+
php artisan horizonxflow:demo-jobs --clear
110+
```
111+
73112
## Upstream Horizon
74113

75114
HorizonXFlow is based on Laravel Horizon and keeps its existing dashboard, queue supervision, metrics, and worker configuration. Refer to the [Laravel Horizon documentation](https://laravel.com/docs/horizon) for inherited Horizon behaviour.

art/retry-parameters.png

144 KB
Loading

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"Composer\\Config::disableProcessTimeout",
8888
"@putenv HORIZONXFLOW_FLOW_SOURCE=mock",
8989
"@build",
90+
"@php vendor/bin/testbench horizonxflow:demo-jobs",
9091
"@php vendor/bin/testbench serve"
9192
],
9293
"lint": [

dist/app.js

Lines changed: 37 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/js/screens/failedJobs/job.vue

Lines changed: 254 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,26 @@
1515
return {
1616
ready: false,
1717
retrying: false,
18-
job: {}
18+
job: {},
19+
showParameters: false,
20+
loadingParameters: false,
21+
parameters: null,
22+
parameterForm: {},
23+
parameterError: null
1924
};
2025
},
2126
2227
28+
computed: {
29+
/**
30+
* Determine whether the job exposes editable parameters.
31+
*/
32+
hasEditableParameters() {
33+
return !! (this.parameters && this.parameters.editable);
34+
}
35+
},
36+
37+
2338
/**
2439
* Prepare the component.
2540
*/
@@ -57,24 +72,179 @@
5772
/**
5873
* Retry the given failed job.
5974
*/
60-
retry(id) {
75+
retry(id, parameters = {}) {
6176
if (this.retrying) {
6277
return;
6378
}
6479
6580
this.retrying = true;
81+
this.parameterError = null;
6682
67-
this.$http.post(Horizon.basePath + '/api/jobs/retry/' + id)
83+
this.$http.post(Horizon.basePath + '/api/jobs/retry/' + id, {parameters})
6884
.then(() => {
6985
setTimeout(() => {
7086
this.reloadRetries();
7187
7288
this.retrying = false;
7389
}, 3000);
90+
})
91+
.catch(error => {
92+
this.retrying = false;
93+
94+
this.parameterError = error.response && error.response.data && error.response.data.message
95+
? error.response.data.message
96+
: 'The job could not be retried.';
7497
});
7598
},
7699
77100
101+
/**
102+
* Toggle the editable parameters panel.
103+
*/
104+
toggleParameters() {
105+
this.showParameters = ! this.showParameters;
106+
107+
if (this.showParameters && this.parameters === null) {
108+
this.loadParameters();
109+
}
110+
},
111+
112+
113+
/**
114+
* Load the parameters that may be overridden for the job.
115+
*/
116+
loadParameters() {
117+
this.loadingParameters = true;
118+
119+
this.$http.get(Horizon.basePath + '/api/jobs/failed/' + this.$route.params.jobId + '/parameters')
120+
.then(response => {
121+
this.parameters = response.data;
122+
this.parameterForm = this.buildParameterForm(response.data.parameters);
123+
this.loadingParameters = false;
124+
})
125+
.catch(() => {
126+
this.parameters = {editable: false, reason: 'The job parameters could not be loaded.', parameters: []};
127+
this.loadingParameters = false;
128+
});
129+
},
130+
131+
132+
/**
133+
* Build the editable form state for the given parameters.
134+
*/
135+
buildParameterForm(parameters) {
136+
let form = {};
137+
138+
parameters.filter(parameter => parameter.editable).forEach(parameter => {
139+
form[parameter.name] = {
140+
type: parameter.type,
141+
nullable: parameter.nullable,
142+
isNull: parameter.value === null,
143+
value: this.stringifyParameter(parameter)
144+
};
145+
});
146+
147+
return form;
148+
},
149+
150+
151+
/**
152+
* Convert a parameter value into its editable representation.
153+
*/
154+
stringifyParameter(parameter) {
155+
let value = parameter.value === null && parameter.default !== null && parameter.default !== undefined
156+
? parameter.default
157+
: parameter.value;
158+
159+
if (parameter.type === 'bool') {
160+
return value === true;
161+
}
162+
163+
if (Array.isArray(value) || (value !== null && typeof value === 'object')) {
164+
return JSON.stringify(value, null, 2);
165+
}
166+
167+
return value === null || value === undefined ? '' : String(value);
168+
},
169+
170+
171+
/**
172+
* Get the value shown for a parameter that may not be edited.
173+
*/
174+
readOnlyValueFor(parameter) {
175+
if (parameter.preview !== null && parameter.preview !== undefined) {
176+
return parameter.preview;
177+
}
178+
179+
return parameter.default === null || parameter.default === undefined
180+
? ''
181+
: JSON.stringify(parameter.default);
182+
},
183+
184+
185+
/**
186+
* Determine the input type to use for the given parameter.
187+
*/
188+
inputTypeFor(parameter) {
189+
if (parameter.type === 'bool') {
190+
return 'boolean';
191+
}
192+
193+
if (parameter.type === 'array' || parameter.type === 'iterable') {
194+
return 'json';
195+
}
196+
197+
return parameter.type === 'int' || parameter.type === 'float' ? 'number' : 'text';
198+
},
199+
200+
201+
/**
202+
* Reset the form back to the job's original parameters.
203+
*/
204+
resetParameters() {
205+
this.parameterError = null;
206+
this.parameterForm = this.buildParameterForm(this.parameters.parameters);
207+
},
208+
209+
210+
/**
211+
* Retry the job using the edited parameters.
212+
*/
213+
retryWithParameters() {
214+
let parameters = {};
215+
216+
for (const [name, field] of Object.entries(this.parameterForm)) {
217+
if (field.isNull) {
218+
parameters[name] = null;
219+
220+
continue;
221+
}
222+
223+
if (field.type === 'bool') {
224+
parameters[name] = !! field.value;
225+
226+
continue;
227+
}
228+
229+
if (field.type === 'array' || field.type === 'iterable') {
230+
try {
231+
parameters[name] = JSON.parse(field.value);
232+
} catch (error) {
233+
this.parameterError = 'The ' + name + ' parameter must contain valid JSON.';
234+
235+
return;
236+
}
237+
238+
continue;
239+
}
240+
241+
parameters[name] = field.value;
242+
}
243+
244+
this.retry(this.job.id, parameters);
245+
},
246+
247+
78248
/**
79249
* Pretty print serialized job.
80250
*
@@ -102,13 +272,19 @@
102272
<h2 class="h6 m-0" v-if="!ready">Job Preview</h2>
103273
<h2 class="h6 m-0" v-if="ready">{{job.name}}</h2>
104274

105-
<button class="btn btn-primary" v-on:click.prevent="retry(job.id)">
106-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor" :class="{spin: retrying}">
107-
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
108-
</svg>
275+
<div class="d-flex align-items-center">
276+
<button class="btn btn-secondary me-2" v-if="ready" v-on:click.prevent="toggleParameters">
277+
{{ showParameters ? 'Hide Parameters' : 'Edit Parameters' }}
278+
</button>
279+
280+
<button class="btn btn-primary" v-on:click.prevent="retry(job.id)">
281+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor" :class="{spin: retrying}">
282+
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
283+
</svg>
109284

110-
Retry
111-
</button>
285+
Retry
286+
</button>
287+
</div>
112288
</div>
113289

114290
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
@@ -171,6 +347,75 @@
171347
</div>
172348
</div>
173349

350+
<div class="card overflow-hidden mt-4" v-if="ready && showParameters">
351+
<div class="card-header d-flex align-items-center justify-content-between">
352+
<h2 class="h6 m-0">Retry Parameters</h2>
353+
</div>
354+
355+
<div v-if="loadingParameters" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
356+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
357+
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
358+
</svg>
359+
360+
<span>Loading...</span>
361+
</div>
362+
363+
<div class="card-body card-bg-secondary" v-if="! loadingParameters && parameters">
364+
<div class="alert alert-danger mb-3" v-if="parameterError">{{ parameterError }}</div>
365+
366+
<p class="text-muted" :class="{'mb-0': ! parameters.parameters.length}" v-if="! hasEditableParameters">{{ parameters.reason }}</p>
367+
368+
<div class="row mb-3" v-for="parameter in parameters.parameters" :key="parameter.name">
369+
<div class="col-md-3">
370+
<label class="mb-0" :class="{'text-muted': ! parameter.editable}" :for="'parameter-' + parameter.name">
371+
{{ parameter.name }}
372+
</label>
373+
<div><small class="text-muted">{{ parameter.type }}</small></div>
374+
</div>
375+
376+
<div class="col" v-if="parameter.editable && parameterForm[parameter.name]">
377+
<select class="form-select" :id="'parameter-' + parameter.name" v-model="parameterForm[parameter.name].value"
378+
v-if="parameter.type === 'bool'" :disabled="parameterForm[parameter.name].isNull">
379+
<option :value="true">true</option>
380+
<option :value="false">false</option>
381+
</select>
382+
383+
<textarea class="form-control font-monospace" :id="'parameter-' + parameter.name" rows="4"
384+
v-model="parameterForm[parameter.name].value"
385+
v-else-if="parameter.type === 'array' || parameter.type === 'iterable'"
386+
:disabled="parameterForm[parameter.name].isNull"></textarea>
387+
388+
<input class="form-control" :id="'parameter-' + parameter.name" v-model="parameterForm[parameter.name].value"
389+
:disabled="parameterForm[parameter.name].isNull"
390+
:type="parameter.type === 'int' || parameter.type === 'float' ? 'number' : 'text'" v-else>
391+
392+
<div class="form-check mt-1" v-if="parameter.nullable">
393+
<input class="form-check-input" type="checkbox" :id="'parameter-null-' + parameter.name"
394+
v-model="parameterForm[parameter.name].isNull">
395+
<label class="form-check-label text-muted" :for="'parameter-null-' + parameter.name">
396+
<small>Send as null</small>
397+
</label>
398+
</div>
399+
</div>
400+
401+
<div class="col" v-else>
402+
<div class="text-muted font-monospace pt-1">{{ readOnlyValueFor(parameter) || '—' }}</div>
403+
<small class="text-muted">{{ parameter.reason }}</small>
404+
</div>
405+
</div>
406+
407+
<div class="d-flex align-items-center" v-if="hasEditableParameters">
408+
<button class="btn btn-primary me-2" :disabled="retrying" v-on:click.prevent="retryWithParameters">
409+
{{ retrying ? 'Retrying...' : 'Retry With Parameters' }}
410+
</button>
411+
412+
<button class="btn btn-secondary" :disabled="retrying" v-on:click.prevent="resetParameters">
413+
Reset
414+
</button>
415+
</div>
416+
</div>
417+
</div>
418+
174419
<div class="card overflow-hidden mt-4" v-if="ready">
175420
<div class="card-header d-flex align-items-center justify-content-between">
176421
<h2 class="h6 m-0">Exception</h2>

resources/views/layout.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
<li class="nav-item">
6262
<router-link active-class="active" to="/live-flow" class="nav-link d-flex align-items-center">
6363
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
64-
<path fill-rule="evenodd" d="M2 5.25A3.25 3.25 0 015.25 2h1.5A3.25 3.25 0 0110 5.25v.25h4.25a3.75 3.75 0 010 7.5H10v.25a3.25 3.25 0 01-3.25 3.25h-1.5a3.25 3.25 0 010-6.5h1.5A3.25 3.25 0 0110 13.25V11.5h4.25a2.25 2.25 0 000-4.5H10v.25a3.25 3.25 0 01-3.25 3.25h-1.5A3.25 3.25 0 012 7.25v-2zm3.25-1.75a1.75 1.75 0 000 3.5h1.5a1.75 1.75 0 000-3.5h-1.5zm0 8a1.75 1.75 0 000 3.5h1.5a1.75 1.75 0 000-3.5h-1.5z" clip-rule="evenodd" />
64+
<path fill-rule="evenodd" d="M11.983 1.907a.75.75 0 00-1.292-.657l-8.5 9.5A.75.75 0 002.75 12h6.572l-1.305 6.093a.75.75 0 001.292.657l8.5-9.5A.75.75 0 0017.25 8h-6.572l1.305-6.093z" clip-rule="evenodd" />
6565
</svg>
6666
<span>Live Flow</span>
6767
</router-link>

0 commit comments

Comments
 (0)