@artian-techs/adonis-orbit 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -45,10 +45,16 @@ Under it, one button reads the page in the light or in the dark. The page reads
45
45
  the mode of the machine while nobody chose; a choice is held in a cookie. The
46
46
  page holds no JavaScript.
47
47
 
48
+ The list carries a pager over the table and under it: the number of every page
49
+ that stands near the one that is read, the first, the last, and a gap between
50
+ them. **Rows** says how many jobs one page holds — 25, 50 or 100. The choice is
51
+ held in a cookie, thus the menu carries it from one status to the next. Without
52
+ one, fifty.
53
+
48
54
  | Entry | What it shows |
49
55
  | --- | --- |
50
56
  | Overview | four numbers, then the last jobs of every status |
51
- | Pending, Running, Retrying, Completed, Failed | the same list, of one status |
57
+ | Pending, Running, Retrying, Completed, Failed, Lost | the same list, of one status |
52
58
  | Silenced | the classes that the lists leave out |
53
59
  | Metrics | what each class and each queue cost over the last 24 hours |
54
60
 
@@ -203,6 +209,7 @@ One row for one job, completed at each step.
203
209
  | `retrying` | a try failed, the next one is set |
204
210
  | `completed` | done |
205
211
  | `failed` | every try fell |
212
+ | `lost` | the queue let the job go, and no worker said how it ended |
206
213
 
207
214
  The row that the dispatch writes and the row that the worker writes are the
208
215
  same row. Either of the two can arrive first, thus the first one writes and the
@@ -210,8 +217,8 @@ second one completes.
210
217
 
211
218
  ## Ask a job again
212
219
 
213
- From the page, with the button on a row that failed. Or with the job that the
214
- package carries:
220
+ From the page, with the button on a row that failed or that the queue lost. Or
221
+ with the job that the package carries:
215
222
 
216
223
  ```ts
217
224
  import { JobRun, JobRunStatus, retryRun } from '@artian-techs/adonis-orbit'
@@ -261,6 +268,44 @@ them away at the end.
261
268
  every status to read and the name `orbit` and the word `orbit`. It reads a
262
269
  database of its own, `orbit_preview`, and makes it if it is not there.
263
270
 
271
+ ## Close the rows that wait for nothing
272
+
273
+ A worker publishes what it does, and Orbit reads it. A worker that refuses a
274
+ job **before it starts** publishes nothing: it fails the job in the queue and
275
+ takes the next one. This happens at every deployment where the worker restarts
276
+ after the API, because the worker of the old build does not carry the class
277
+ that the new API asks for.
278
+
279
+ The row of that job stays `pending`, and the page shows a job that waits for a
280
+ worker that will never take it.
281
+
282
+ `ReconcileJobRuns` asks the queue about every row that stayed open too long:
283
+
284
+ ```ts
285
+ import { ReconcileJobRuns } from '@artian-techs/adonis-orbit'
286
+
287
+ await ReconcileJobRuns.schedule({ minutes: 5 }).cron('* * * * *').run()
288
+ ```
289
+
290
+ - the queue holds the job, thus the row is right and stays as it is;
291
+ - the queue holds the answer, thus the row takes it, with its error;
292
+ - the queue holds nothing, thus the row becomes `lost`.
293
+
294
+ `minutes` says how long a row stays open before the queue is asked about it.
295
+ It leaves the events of the worker the time to land: a row that a worker closed
296
+ a second ago is never read. Without it, five minutes.
297
+
298
+ The rows are read from the table, thus this needs `DatabaseSink`. The answers
299
+ go to every sink of the configuration, as the events of the worker do.
300
+
301
+ A queue that keeps its failed jobs gives the error that stopped them. Ask for
302
+ it in `config/queue.ts`, else the queue removes a job as soon as it fails and
303
+ the row can only say `lost`:
304
+
305
+ ```ts
306
+ defaultJobOptions: { removeOnFail: false },
307
+ ```
308
+
264
309
  ## Take out the traces that ended well
265
310
 
266
311
  The package carries `PruneJobRuns` and names it to the queue itself. Give it a
package/build/index.d.ts CHANGED
@@ -5,6 +5,8 @@ export type { Authorize, JobEvent, JobIdentity, JobOutcome, OrbitConfig, Payload
5
5
  export { default as JobRun, JobRunStatus, type JobRunStatusValue } from './src/models/job_run.js';
6
6
  export { default as SilencedJob } from './src/models/silenced_job.js';
7
7
  export { default as PruneJobRuns } from './src/jobs/prune_job_runs.js';
8
+ export { default as ReconcileJobRuns } from './src/jobs/reconcile_job_runs.js';
9
+ export { reconcileJobRuns, type QueueLookup, type ReconcileOptions } from './src/reconcile.js';
8
10
  export { retryRun } from './src/retry.js';
9
11
  export { stubsRoot } from './stubs/main.js';
10
12
  export { configure } from './configure.js';
package/build/index.js CHANGED
@@ -4,6 +4,8 @@ export { default as DatabaseSink } from './src/sinks/database_sink.js';
4
4
  export { default as JobRun, JobRunStatus } from './src/models/job_run.js';
5
5
  export { default as SilencedJob } from './src/models/silenced_job.js';
6
6
  export { default as PruneJobRuns } from './src/jobs/prune_job_runs.js';
7
+ export { default as ReconcileJobRuns } from './src/jobs/reconcile_job_runs.js';
8
+ export { reconcileJobRuns } from './src/reconcile.js';
7
9
  export { retryRun } from './src/retry.js';
8
10
  export { stubsRoot } from './stubs/main.js';
9
11
  export { configure } from './configure.js';
@@ -1,6 +1,7 @@
1
1
  import { Locator } from '@adonisjs/queue';
2
2
  import edge from 'edge.js';
3
3
  import PruneJobRuns from '../src/jobs/prune_job_runs.js';
4
+ import ReconcileJobRuns from '../src/jobs/reconcile_job_runs.js';
4
5
  const DEFAULTS = { path: '/queue', authorize: () => false, sinks: [] };
5
6
  /**
6
7
  * Watch what the queue does, and keep it.
@@ -26,8 +27,17 @@ export default class OrbitProvider {
26
27
  * lives here. It names itself to the queue.
27
28
  */
28
29
  Locator.register(PruneJobRuns.name, PruneJobRuns);
30
+ Locator.register(ReconcileJobRuns.name, ReconcileJobRuns);
29
31
  const { recordJobRuns } = await import('../src/recorder.js');
30
- recordJobRuns(config);
32
+ const recorder = recordJobRuns(config);
33
+ /**
34
+ * A sink answers after the job does, and the application closes its
35
+ * database when it stops. The writes that are open are waited for first.
36
+ *
37
+ * This hook runs before the providers stop, thus the database of the
38
+ * application is still open.
39
+ */
40
+ this.app.terminating(() => recorder.flush());
31
41
  const router = await this.app.container.make('router');
32
42
  const { registerRoutes } = await import('../src/routes.js');
33
43
  registerRoutes(router, config);
@@ -5,6 +5,10 @@ import type { HttpContext } from '@adonisjs/core/http';
5
5
  * It reads the table, not Redis: a job that ended left Redis, and the table
6
6
  * keeps it. The page is served by the API itself, outside the front, thus it
7
7
  * answers even when the front is down.
8
+ *
9
+ * A button answers with the page that carried it, and that page keeps the
10
+ * values of its address: a list of one status stays on that status, and a page
11
+ * of a pager stays on its number.
8
12
  */
9
13
  export default class DashboardController {
10
14
  #private;
@@ -16,6 +20,13 @@ export default class DashboardController {
16
20
  * the other, the page shows the numbers and the last jobs of every status.
17
21
  */
18
22
  index(ctx: HttpContext): Promise<void>;
23
+ /**
24
+ * Read more rows on one page, or fewer.
25
+ *
26
+ * The choice is held in a cookie, as the mode is. A page that stands past
27
+ * the last one after the change answers with the last one.
28
+ */
29
+ perPage({ params, response }: HttpContext): Promise<void>;
19
30
  /**
20
31
  * The classes that the lists leave out.
21
32
  *
@@ -7,11 +7,20 @@ import JobRun, { JobRunStatus } from '../models/job_run.js';
7
7
  import SilencedJob from '../models/silenced_job.js';
8
8
  import { retryRun } from '../retry.js';
9
9
  const PER_PAGE = 50;
10
+ /** The sizes of a page that a person chooses between. */
11
+ const PER_PAGE_SIZES = [25, 50, 100];
12
+ /** Where the size of a page is held, and for how long. */
13
+ const PER_PAGE_COOKIE = 'orbit_per_page';
14
+ const PER_PAGE_AGE = '1y';
15
+ /** How many numbers of pages stand on each side of the one that is read. */
16
+ const PAGER_WINDOW = 2;
10
17
  /** The two modes that a person chooses between. */
11
18
  const THEMES = ['light', 'dark'];
12
19
  /** Where the choice of the mode is held, and for how long. */
13
20
  const THEME_COOKIE = 'orbit_theme';
14
21
  const THEME_AGE = '1y';
22
+ /** The statuses of a job that a person may ask for again. */
23
+ const ENDED_BADLY = [JobRunStatus.FAILED, JobRunStatus.LOST];
15
24
  /** How far back the metrics read. */
16
25
  const METRICS_WINDOW = { hours: 24 };
17
26
  const STATUS_LABELS = {
@@ -20,6 +29,7 @@ const STATUS_LABELS = {
20
29
  [JobRunStatus.RETRYING]: 'Retrying',
21
30
  [JobRunStatus.COMPLETED]: 'Completed',
22
31
  [JobRunStatus.FAILED]: 'Failed',
32
+ [JobRunStatus.LOST]: 'Lost',
23
33
  };
24
34
  /**
25
35
  * The drawings of the side menu. They are written here, and the page prints
@@ -32,6 +42,7 @@ const ICONS = {
32
42
  retrying: '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/>',
33
43
  completed: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
34
44
  failed: '<circle cx="12" cy="12" r="9"/><path d="M12 7v6"/><path d="M12 16.5v.5"/>',
45
+ lost: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9.5l5 5"/><path d="M14.5 9.5l-5 5"/>',
35
46
  silenced: '<path d="M18 8a6 6 0 0 0-9.3-5"/><path d="M6 9v5l-2 3h13"/><path d="M10.5 20a2 2 0 0 0 3 0"/><path d="M3 3l18 18"/>',
36
47
  metrics: '<path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/>',
37
48
  };
@@ -41,6 +52,10 @@ const ICONS = {
41
52
  * It reads the table, not Redis: a job that ended left Redis, and the table
42
53
  * keeps it. The page is served by the API itself, outside the front, thus it
43
54
  * answers even when the front is down.
55
+ *
56
+ * A button answers with the page that carried it, and that page keeps the
57
+ * values of its address: a list of one status stays on that status, and a page
58
+ * of a pager stays on its number.
44
59
  */
45
60
  export default class DashboardController {
46
61
  #silenced;
@@ -57,21 +72,32 @@ export default class DashboardController {
57
72
  const job = request.input('job');
58
73
  const page = Number(request.input('page', 1)) || 1;
59
74
  const silenced = await this.#silencedNames();
60
- const query = JobRun.query().orderBy('id', 'desc');
61
- if (status) {
62
- query.where('status', status);
63
- }
75
+ const perPage = this.#perPage(request);
76
+ const build = () => {
77
+ const query = JobRun.query().orderBy('id', 'desc');
78
+ if (status) {
79
+ query.where('status', status);
80
+ }
81
+ /**
82
+ * A person who asks for one job asks for that job, thus a class that is
83
+ * silenced answers here as any other class.
84
+ */
85
+ if (job) {
86
+ query.where('jobId', job);
87
+ }
88
+ else if (silenced.length > 0) {
89
+ query.whereNotIn('name', silenced);
90
+ }
91
+ return query;
92
+ };
93
+ let runs = await build().paginate(page, perPage);
64
94
  /**
65
- * A person who asks for one job asks for that job, thus a class that is
66
- * silenced answers here as any other class.
95
+ * A page that stands past the last one holds nothing. The last page holds
96
+ * the rows that the person asked for.
67
97
  */
68
- if (job) {
69
- query.where('jobId', job);
98
+ if (page > runs.lastPage && runs.lastPage > 0) {
99
+ runs = await build().paginate(runs.lastPage, perPage);
70
100
  }
71
- else if (silenced.length > 0) {
72
- query.whereNotIn('name', silenced);
73
- }
74
- const runs = await query.paginate(page, PER_PAGE);
75
101
  const answers = await this.#answers(runs.all());
76
102
  const alone = Boolean(status || job);
77
103
  return this.#render(ctx, 'index', {
@@ -81,10 +107,89 @@ export default class DashboardController {
81
107
  stats: alone ? null : await this.#stats(),
82
108
  status: status ?? '',
83
109
  rows: runs.all().map((run) => this.#row(run, silenced, answers)),
84
- page: runs.currentPage,
85
- lastPage: runs.lastPage,
110
+ pager: this.#pager(runs, { status, job }),
111
+ perPage: { current: perPage, sizes: PER_PAGE_SIZES },
112
+ });
113
+ }
114
+ /**
115
+ * How many rows one page holds.
116
+ *
117
+ * The choice is held in a cookie, thus the menu carries it from one status
118
+ * to the next and no address holds it.
119
+ */
120
+ #perPage(request) {
121
+ const size = Number(request.plainCookie(PER_PAGE_COOKIE));
122
+ return PER_PAGE_SIZES.includes(size) ? size : PER_PAGE;
123
+ }
124
+ /**
125
+ * Every address that the pager leads to. The page reads them, it builds
126
+ * none.
127
+ *
128
+ * The numbers stand around the page that is read, and the first and the last
129
+ * stand at the ends. A hole between two of them is a step that the page
130
+ * writes as a gap.
131
+ */
132
+ #pager(runs, filters) {
133
+ const current = runs.currentPage;
134
+ const last = runs.lastPage;
135
+ const href = (page) => this.#href({ ...filters, page: page > 1 ? page : undefined });
136
+ const pager = {
137
+ page: current,
138
+ lastPage: last,
86
139
  total: runs.total,
140
+ previous: current > 1 ? href(current - 1) : null,
141
+ next: current < last ? href(current + 1) : null,
142
+ pages: [],
143
+ };
144
+ if (last < 2) {
145
+ return pager;
146
+ }
147
+ const numbers = new Set([1, last]);
148
+ for (let number = current - PAGER_WINDOW; number <= current + PAGER_WINDOW; number++) {
149
+ if (number >= 1 && number <= last) {
150
+ numbers.add(number);
151
+ }
152
+ }
153
+ let previous = 0;
154
+ for (const number of [...numbers].sort((a, b) => a - b)) {
155
+ if (previous > 0 && number - previous > 1) {
156
+ pager.pages.push({ number: null, href: null, current: false });
157
+ }
158
+ pager.pages.push({ number, href: href(number), current: number === current });
159
+ previous = number;
160
+ }
161
+ return pager;
162
+ }
163
+ /** One address of the page, with the values that name what it shows. */
164
+ #href(params) {
165
+ const query = new URLSearchParams();
166
+ for (const [key, value] of Object.entries(params)) {
167
+ if (value !== undefined && value !== '') {
168
+ query.set(key, String(value));
169
+ }
170
+ }
171
+ const search = query.toString();
172
+ const { path } = this.#config();
173
+ return search ? `${path}?${search}` : path;
174
+ }
175
+ /**
176
+ * Read more rows on one page, or fewer.
177
+ *
178
+ * The choice is held in a cookie, as the mode is. A page that stands past
179
+ * the last one after the change answers with the last one.
180
+ */
181
+ async perPage({ params, response }) {
182
+ const size = Number(params.count);
183
+ if (!PER_PAGE_SIZES.includes(size)) {
184
+ return response.notFound({ success: false, error: 'Not found' });
185
+ }
186
+ response.plainCookie(PER_PAGE_COOKIE, String(size), {
187
+ path: this.#config().path,
188
+ maxAge: PER_PAGE_AGE,
189
+ httpOnly: false,
190
+ sameSite: 'lax',
87
191
  });
192
+ return response.redirect().withQs().back();
88
193
  }
89
194
  /** What the page is called. */
90
195
  #title(status, job) {
@@ -134,8 +239,8 @@ export default class DashboardController {
134
239
  active: 'metrics',
135
240
  title: 'Metrics',
136
241
  hours: METRICS_WINDOW.hours,
137
- byJob: await this.#metricsBy('name', since),
138
- byQueue: await this.#metricsBy('queue', since),
242
+ byJob: await this.#metricsBy(['name', 'queue'], since),
243
+ byQueue: await this.#metricsBy(['queue'], since),
139
244
  });
140
245
  }
141
246
  /**
@@ -147,7 +252,7 @@ export default class DashboardController {
147
252
  async retry({ params, response }) {
148
253
  const run = await JobRun.findOrFail(params.id);
149
254
  await retryRun(run);
150
- return response.redirect().back();
255
+ return response.redirect().withQs().back();
151
256
  }
152
257
  /**
153
258
  * Read the page in the light, or in the dark.
@@ -167,7 +272,7 @@ export default class DashboardController {
167
272
  httpOnly: false,
168
273
  sameSite: 'lax',
169
274
  });
170
- return response.redirect().back();
275
+ return response.redirect().withQs().back();
171
276
  }
172
277
  /** Take a class out of the lists, or put it back. */
173
278
  async silence({ params, response }) {
@@ -179,7 +284,7 @@ export default class DashboardController {
179
284
  else {
180
285
  await SilencedJob.create({ name });
181
286
  }
182
- return response.redirect().back();
287
+ return response.redirect().withQs().back();
183
288
  }
184
289
  /** The four numbers of the overview. */
185
290
  async #stats() {
@@ -201,23 +306,29 @@ export default class DashboardController {
201
306
  return Number(row.total);
202
307
  }
203
308
  /**
204
- * What one column cost over the window.
309
+ * What one column, or two, cost over the window.
205
310
  *
206
311
  * The count of the failures rides with the count of the rows: one reading of
207
312
  * the table answers both.
313
+ *
314
+ * A class that runs on two queues is read twice, once for each queue: the
315
+ * duration of a job is the duration of the queue that carries it, and one
316
+ * average over the two says nothing about either.
208
317
  */
209
- async #metricsBy(column, since) {
210
- const result = await db.rawQuery(`select ${column} as label,
318
+ async #metricsBy(columns, since) {
319
+ const group = columns.join(', ');
320
+ const result = await db.rawQuery(`select ${group},
211
321
  count(*) as total,
212
322
  count(*) filter (where status = ?) as failed,
213
323
  round(avg(duration)::numeric, 1) as average,
214
324
  round(max(duration)::numeric, 1) as longest
215
325
  from ${JobRun.table}
216
326
  where finished_at > ?
217
- group by ${column}
327
+ group by ${group}
218
328
  order by count(*) desc`, [JobRunStatus.FAILED, since]);
219
329
  return result.rows.map((row) => ({
220
- label: row.label,
330
+ label: row[columns[0]],
331
+ queue: columns.length > 1 ? row[columns[1]] : null,
221
332
  total: Number(row.total),
222
333
  failed: Number(row.failed),
223
334
  average: row.average === null ? null : Number(row.average),
@@ -279,7 +390,7 @@ export default class DashboardController {
279
390
  retriedAt: run.retriedAt?.toFormat('dd LLL yyyy HH:mm:ss') ?? null,
280
391
  retriedJobId: run.retriedJobId,
281
392
  retriedStatus: answer ? STATUS_LABELS[answer] : null,
282
- canRetry: run.status === JobRunStatus.FAILED && !run.retriedJobId,
393
+ canRetry: ENDED_BADLY.includes(run.status) && !run.retriedJobId,
283
394
  isSilenced: silenced.includes(run.name),
284
395
  };
285
396
  }
@@ -0,0 +1,18 @@
1
+ import { Job } from '@adonisjs/queue';
2
+ import type { JobOptions } from '@adonisjs/queue/types';
3
+ interface ReconcileJobRunsPayload {
4
+ /** How long a row stays open before the queue is asked about it, in minutes. */
5
+ minutes?: number;
6
+ }
7
+ /**
8
+ * Close the rows that wait for an answer that never comes.
9
+ *
10
+ * A worker that refuses a job before it starts publishes nothing. The row of
11
+ * that job stays open, and the page shows it as a job that waits. This asks
12
+ * the queue about every row that stayed open, and writes what the queue says.
13
+ */
14
+ export default class ReconcileJobRuns extends Job<ReconcileJobRunsPayload> {
15
+ static options: JobOptions;
16
+ execute(): Promise<void>;
17
+ }
18
+ export {};
@@ -0,0 +1,26 @@
1
+ import app from '@adonisjs/core/services/app';
2
+ import logger from '@adonisjs/core/services/logger';
3
+ import { Job } from '@adonisjs/queue';
4
+ import { reconcileJobRuns } from '../reconcile.js';
5
+ /**
6
+ * Close the rows that wait for an answer that never comes.
7
+ *
8
+ * A worker that refuses a job before it starts publishes nothing. The row of
9
+ * that job stays open, and the page shows it as a job that waits. This asks
10
+ * the queue about every row that stayed open, and writes what the queue says.
11
+ */
12
+ export default class ReconcileJobRuns extends Job {
13
+ static options = {
14
+ queue: 'default',
15
+ maxRetries: 1,
16
+ };
17
+ async execute() {
18
+ const config = app.config.get('orbit', {
19
+ path: '/queue',
20
+ authorize: () => false,
21
+ sinks: [],
22
+ });
23
+ const events = await reconcileJobRuns(config, { minutes: this.payload.minutes });
24
+ logger.info(`${events.length} trace(s) of jobs closed.`);
25
+ }
26
+ }
@@ -6,6 +6,9 @@ import type { DateTime } from 'luxon';
6
6
  * `pending` and `running` are what the worker is about to do or does now.
7
7
  * `completed` and `failed` are what it did. `retrying` names a job that failed
8
8
  * a try and waits for the next one.
9
+ *
10
+ * `lost` names a job that the queue does not hold any more, and that no worker
11
+ * said anything about.
9
12
  */
10
13
  export declare const JobRunStatus: {
11
14
  readonly PENDING: "pending";
@@ -13,6 +16,7 @@ export declare const JobRunStatus: {
13
16
  readonly RETRYING: "retrying";
14
17
  readonly COMPLETED: "completed";
15
18
  readonly FAILED: "failed";
19
+ readonly LOST: "lost";
16
20
  };
17
21
  export type JobRunStatusValue = (typeof JobRunStatus)[keyof typeof JobRunStatus];
18
22
  export default class JobRun extends BaseModel {
@@ -11,6 +11,9 @@ import { BaseModel, column } from '@adonisjs/lucid/orm';
11
11
  * `pending` and `running` are what the worker is about to do or does now.
12
12
  * `completed` and `failed` are what it did. `retrying` names a job that failed
13
13
  * a try and waits for the next one.
14
+ *
15
+ * `lost` names a job that the queue does not hold any more, and that no worker
16
+ * said anything about.
14
17
  */
15
18
  export const JobRunStatus = {
16
19
  PENDING: 'pending',
@@ -18,6 +21,7 @@ export const JobRunStatus = {
18
21
  RETRYING: 'retrying',
19
22
  COMPLETED: 'completed',
20
23
  FAILED: 'failed',
24
+ LOST: 'lost',
21
25
  };
22
26
  export default class JobRun extends BaseModel {
23
27
  }
@@ -0,0 +1,42 @@
1
+ import type { JobEvent, OrbitConfig } from './types.js';
2
+ /**
3
+ * Who answers about one job.
4
+ *
5
+ * The queue of the application answers this, and so does a queue of a test.
6
+ * The words are those of a queue, and of no framework.
7
+ */
8
+ export interface QueueLookup {
9
+ getJob(id: string, queue: string): Promise<{
10
+ status: string;
11
+ error?: string;
12
+ } | null>;
13
+ }
14
+ export interface ReconcileOptions {
15
+ /** How long a row stays open before the queue is asked about it, in minutes. */
16
+ minutes?: number;
17
+ /** How many rows one call reads. */
18
+ limit?: number;
19
+ /** Who answers about a job. Without one, the queue of the application. */
20
+ queue?: QueueLookup;
21
+ }
22
+ /**
23
+ * Ask the queue about the jobs that the table still calls open.
24
+ *
25
+ * A worker publishes what it does, and the recorder reads it. A worker that
26
+ * refuses a job before it starts publishes nothing: it fails the job in the
27
+ * queue and takes the next one. The row of that job then waits for an answer
28
+ * that never comes.
29
+ *
30
+ * This reads the rows that stayed open, and asks the queue about each one:
31
+ *
32
+ * - the queue holds the job, thus the row is right and stays as it is;
33
+ * - the queue holds the answer, thus the row takes it;
34
+ * - the queue holds nothing, thus the job is `lost`.
35
+ *
36
+ * The grace leaves the events of the worker the time to land. A row that a
37
+ * worker closed a second ago is not read here.
38
+ *
39
+ * The rows are read from the table, thus this needs `DatabaseSink`. The
40
+ * answers go to every sink of the configuration.
41
+ */
42
+ export declare function reconcileJobRuns(config: OrbitConfig, options?: ReconcileOptions): Promise<JobEvent[]>;
@@ -0,0 +1,81 @@
1
+ import logger from '@adonisjs/core/services/logger';
2
+ import { QueueManager } from '@adonisjs/queue';
3
+ import { DateTime } from 'luxon';
4
+ import JobRun, { JobRunStatus } from './models/job_run.js';
5
+ /** The statuses of a row that waits for an answer. */
6
+ const OPEN = [JobRunStatus.PENDING, JobRunStatus.RUNNING, JobRunStatus.RETRYING];
7
+ /** The statuses that the queue gives to a job that it still holds. */
8
+ const HELD = ['pending', 'active', 'delayed'];
9
+ /** What a row carries when the queue lets the job go and says nothing. */
10
+ const LOST = 'The queue does not hold this job any more. No worker said how it ended.';
11
+ /** How long a row stays open before the queue is asked about it, in minutes. */
12
+ const GRACE = 5;
13
+ /** How many rows one call reads. */
14
+ const LIMIT = 500;
15
+ /**
16
+ * Ask the queue about the jobs that the table still calls open.
17
+ *
18
+ * A worker publishes what it does, and the recorder reads it. A worker that
19
+ * refuses a job before it starts publishes nothing: it fails the job in the
20
+ * queue and takes the next one. The row of that job then waits for an answer
21
+ * that never comes.
22
+ *
23
+ * This reads the rows that stayed open, and asks the queue about each one:
24
+ *
25
+ * - the queue holds the job, thus the row is right and stays as it is;
26
+ * - the queue holds the answer, thus the row takes it;
27
+ * - the queue holds nothing, thus the job is `lost`.
28
+ *
29
+ * The grace leaves the events of the worker the time to land. A row that a
30
+ * worker closed a second ago is not read here.
31
+ *
32
+ * The rows are read from the table, thus this needs `DatabaseSink`. The
33
+ * answers go to every sink of the configuration.
34
+ */
35
+ export async function reconcileJobRuns(config, options = {}) {
36
+ const before = DateTime.now().minus({ minutes: options.minutes ?? GRACE });
37
+ const open = await JobRun.query()
38
+ .whereIn('status', [...OPEN])
39
+ .where('updatedAt', '<', before.toJSDate())
40
+ .orderBy('id', 'asc')
41
+ .limit(options.limit ?? LIMIT);
42
+ if (open.length === 0) {
43
+ return [];
44
+ }
45
+ const queue = options.queue ?? QueueManager.use();
46
+ const at = new Date();
47
+ const events = [];
48
+ for (const run of open) {
49
+ const record = await queue.getJob(run.jobId, run.queue);
50
+ if (record && HELD.includes(record.status)) {
51
+ continue;
52
+ }
53
+ const outcome = record ? record.status : 'lost';
54
+ events.push({
55
+ type: 'finished',
56
+ at,
57
+ job: {
58
+ id: run.jobId,
59
+ name: run.name,
60
+ queue: run.queue,
61
+ payload: run.payload ?? {},
62
+ attempts: run.attempts,
63
+ },
64
+ outcome,
65
+ duration: null,
66
+ error: record ? record.error || null : LOST,
67
+ });
68
+ }
69
+ if (events.length === 0) {
70
+ return [];
71
+ }
72
+ for (const sink of config.sinks ?? []) {
73
+ try {
74
+ await sink.write(events);
75
+ }
76
+ catch (error) {
77
+ logger.error({ err: error }, 'The trace of a job could not be written');
78
+ }
79
+ }
80
+ return events;
81
+ }
@@ -10,5 +10,10 @@ import type { OrbitConfig } from './types.js';
10
10
  * A sink that refuses an event must never stop the job that runs.
11
11
  *
12
12
  * A configuration that names no sink keeps nothing.
13
+ *
14
+ * The recorder gives back a `flush`. It waits for the writes that are open,
15
+ * and the application calls it before it closes what the sinks use.
13
16
  */
14
- export declare function recordJobRuns(config: OrbitConfig): void;
17
+ export declare function recordJobRuns(config: OrbitConfig): {
18
+ flush: () => Promise<void>;
19
+ };
@@ -47,15 +47,35 @@ function keep(rule, job, payload) {
47
47
  * A sink that refuses an event must never stop the job that runs.
48
48
  *
49
49
  * A configuration that names no sink keeps nothing.
50
+ *
51
+ * The recorder gives back a `flush`. It waits for the writes that are open,
52
+ * and the application calls it before it closes what the sinks use.
50
53
  */
51
54
  export function recordJobRuns(config) {
52
55
  const sinks = config.sinks ?? [];
53
56
  const rule = config.payload ?? 'full';
57
+ /** The writes that are started, and that no sink finished yet. */
58
+ const pending = new Set();
54
59
  function write(events) {
55
60
  for (const sink of sinks) {
56
- void sink.write(events).catch((error) => {
61
+ const done = sink.write(events).catch((error) => {
57
62
  logger.error({ err: error }, 'The trace of a job could not be written');
58
63
  });
64
+ pending.add(done);
65
+ void done.finally(() => pending.delete(done));
66
+ }
67
+ }
68
+ /**
69
+ * Wait for every write that is open.
70
+ *
71
+ * A command puts a job in the queue and stops at once. The writes of the
72
+ * sinks are still open at that moment, and the application closes its
73
+ * database after this call. A write that a sink starts while this one waits
74
+ * is waited for too.
75
+ */
76
+ async function flush() {
77
+ while (pending.size > 0) {
78
+ await Promise.all(pending);
59
79
  }
60
80
  }
61
81
  function identify(job, queue) {
@@ -99,4 +119,5 @@ export function recordJobRuns(config) {
99
119
  ]);
100
120
  },
101
121
  });
122
+ return { flush };
102
123
  }
@@ -14,6 +14,7 @@ export function registerRoutes(router, config) {
14
14
  router.post('/runs/:id/retry', [DashboardController, 'retry']).as('orbit.retry');
15
15
  router.post('/jobs/:name/silence', [DashboardController, 'silence']).as('orbit.silence');
16
16
  router.post('/theme/:theme', [DashboardController, 'theme']).as('orbit.theme');
17
+ router.post('/per-page/:count', [DashboardController, 'perPage']).as('orbit.perPage');
17
18
  })
18
19
  .prefix(config.path)
19
20
  .use(dashboardAuth(config));
@@ -5,6 +5,7 @@ const STATUS = {
5
5
  completed: JobRunStatus.COMPLETED,
6
6
  failed: JobRunStatus.FAILED,
7
7
  retrying: JobRunStatus.RETRYING,
8
+ lost: JobRunStatus.LOST,
8
9
  };
9
10
  /**
10
11
  * Keep the trace of the jobs in the table of the application.
@@ -74,7 +75,7 @@ export default class DatabaseSink {
74
75
  }
75
76
  /** Say how the try of a job ended. */
76
77
  #close({ job, at, outcome, duration, error }) {
77
- const ended = outcome === 'completed' || outcome === 'failed';
78
+ const ended = outcome !== 'retrying';
78
79
  return JobRun.query()
79
80
  .where('jobId', job.id)
80
81
  .update({
@@ -25,8 +25,14 @@ export interface JobIdentity {
25
25
  /** How many times a worker took the job. */
26
26
  attempts: number;
27
27
  }
28
- /** How a try of a job ended. */
29
- export type JobOutcome = 'completed' | 'failed' | 'retrying';
28
+ /**
29
+ * How a try of a job ended.
30
+ *
31
+ * `lost` names a job that the queue does not hold any more, and that no worker
32
+ * said anything about. A worker that stops between the two says nothing, and
33
+ * the job would wait in the trace for ever.
34
+ */
35
+ export type JobOutcome = 'completed' | 'failed' | 'retrying' | 'lost';
30
36
  /**
31
37
  * One step of one job.
32
38
  *
@@ -104,6 +104,7 @@
104
104
  .retrying { background: #fef3c7; color: #92400e; }
105
105
  .completed { background: #d1fae5; color: #065f46; }
106
106
  .failed { background: #fee2e2; color: #991b1b; }
107
+ .lost { background: #ede9fe; color: #5b21b6; }
107
108
  .muted { color: var(--muted); font-size: 12px; }
108
109
  .actions { display: flex; gap: 6px; align-items: center; }
109
110
  .actions a { color: inherit; white-space: nowrap; }
@@ -117,8 +118,23 @@
117
118
  overflow-x: auto; font-size: 12px; max-width: 520px; white-space: pre-wrap;
118
119
  }
119
120
  details summary { cursor: pointer; color: var(--muted); font-size: 12px; }
120
- .pager { display: flex; gap: 14px; align-items: center; margin-top: 16px; }
121
+ .pager {
122
+ display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
123
+ margin: 16px 0; font-size: 13px;
124
+ }
121
125
  .pager a { color: inherit; }
126
+ .pager form { display: inline; }
127
+ .page {
128
+ display: inline-block; min-width: 30px; padding: 4px 8px; border-radius: 6px;
129
+ border: 1px solid var(--line); background: var(--card); text-align: center;
130
+ text-decoration: none; font: inherit; font-size: 13px; color: inherit; cursor: pointer;
131
+ }
132
+ a.page:hover, button.page:hover { border-color: var(--accent); color: var(--accent); }
133
+ .page.current {
134
+ border-color: var(--accent); background: var(--accent-soft);
135
+ color: var(--accent); font-weight: 600; cursor: default;
136
+ }
137
+ .rows { display: flex; gap: 6px; align-items: center; margin-left: auto; }
122
138
  .empty { padding: 28px; text-align: center; color: var(--muted); }
123
139
  </style>
124
140
  </head>
@@ -0,0 +1,39 @@
1
+ <div class="pager">
2
+ <span class="muted">{{ pager.total }} job(s)</span>
3
+
4
+ @if(pager.previous)
5
+ <a href="{{ pager.previous }}">← Previous</a>
6
+ @end
7
+
8
+ @each(entry in pager.pages)
9
+ @if(entry.number === null)
10
+ <span class="muted">…</span>
11
+ @elseif(entry.current)
12
+ <span class="page current">{{ entry.number }}</span>
13
+ @else
14
+ <a class="page" href="{{ entry.href }}">{{ entry.number }}</a>
15
+ @end
16
+ @end
17
+
18
+ @if(pager.next)
19
+ <a href="{{ pager.next }}">Next →</a>
20
+ @end
21
+
22
+ @if(rows)
23
+ <span class="rows">
24
+ <span class="muted">Rows</span>
25
+ @each(size in perPage.sizes)
26
+ @if(size === perPage.current)
27
+ <span class="page current">{{ size }}</span>
28
+ @else
29
+ <form method="post" action="{{ path }}/per-page/{{ size }}">
30
+ @if(csrfToken)
31
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
32
+ @end
33
+ <button type="submit" class="page">{{ size }}</button>
34
+ </form>
35
+ @end
36
+ @end
37
+ </span>
38
+ @end
39
+ </div>
@@ -22,6 +22,8 @@
22
22
  <h2>Recent jobs</h2>
23
23
  @end
24
24
 
25
+ @!component('orbit::components/pager', { pager, path, csrfToken, perPage, rows: true })
26
+
25
27
  <div class="panel">
26
28
  <table>
27
29
  <thead>
@@ -99,13 +101,5 @@
99
101
  </table>
100
102
  </div>
101
103
 
102
- <div class="pager">
103
- @if(page > 1)
104
- <a href="{{ path }}?page={{ page - 1 }}&status={{ status }}">← Previous</a>
105
- @end
106
- <span class="muted">Page {{ page }} of {{ lastPage }} — {{ total }} job(s)</span>
107
- @if(page < lastPage)
108
- <a href="{{ path }}?page={{ page + 1 }}&status={{ status }}">Next →</a>
109
- @end
110
- </div>
104
+ @!component('orbit::components/pager', { pager, path, csrfToken, perPage, rows: true })
111
105
  @end
@@ -7,6 +7,7 @@
7
7
  <thead>
8
8
  <tr>
9
9
  <th>Job</th>
10
+ <th>Queue</th>
10
11
  <th>Ended</th>
11
12
  <th>Failed</th>
12
13
  <th>Average</th>
@@ -17,13 +18,14 @@
17
18
  @each(row in byJob)
18
19
  <tr>
19
20
  <td><b>{{ row.label }}</b></td>
21
+ <td>{{ row.queue }}</td>
20
22
  <td>{{ row.total }}</td>
21
23
  <td>{{ row.failed }}</td>
22
24
  <td>{{ row.average === null ? '—' : row.average + ' ms' }}</td>
23
25
  <td>{{ row.longest === null ? '—' : row.longest + ' ms' }}</td>
24
26
  </tr>
25
27
  @else
26
- <tr><td colspan="5" class="empty">No job ended in this window.</td></tr>
28
+ <tr><td colspan="6" class="empty">No job ended in this window.</td></tr>
27
29
  @end
28
30
  </tbody>
29
31
  </table>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artian-techs/adonis-orbit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "See what the queue of AdonisJS does, keep it, and ask a job again.",
6
6
  "main": "build/index.js",