lepus-saga-sequel 0.3.0 → 0.3.1
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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +9 -8
- data/lib/lepus/saga/sequel/execution.rb +1 -1
- data/lib/lepus/saga/sequel/migrations/001_create_lepus_saga_tables.rb +7 -4
- data/lib/lepus/saga/sequel/version.rb +1 -1
- data/lib/lepus/saga/stores/sequel.rb +53 -8
- metadata +3 -4
- data/lib/lepus/saga/sequel/migrations/002_add_lepus_saga_dashboard_indexes.rb +0 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ab2a2679b384739bc0e66d34a24999eea651f6e4d2f8d1cef2de9cc1c5caed1
|
|
4
|
+
data.tar.gz: da5a456ca77c48694aa8ba157836850a80cf4cf996225b42ed1b1e4c6217aa72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ce0d02bddd34b58f0e27e3a2064e05cc755b035aa4bafe3d0fcadcfd4469d236e554b52d0b59874af70857c191fd0de9d269af652012b5c5a87dc0ada412d07
|
|
7
|
+
data.tar.gz: 21e9d75ea12a719c97c25354cbca1eb864838fa2eff443d1210d574b2014e03a4abdd6e0a3ce1e30cdb3be6cd1ccb38b10048db0e3b3b9eeb9be8cbe31c62087
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 0.3.1 - Unreleased
|
|
8
|
+
* Fixed: executions list in time order when their ids are not time-ordered (e.g. random UUIDv4): `list_executions` sorts and pages by (sort value, id) with `after`, `executions_for_resource` and `recent_first` order by started_at, then id
|
|
9
|
+
* Added: the `duration_ms` bigint column, the `sort`/`direction`/`after` options of `list_executions` (keyset paging in at most two queries that start the `(column, id)` index at the cursor, executions without a duration last), the `finished_from`/`finished_to` filters and `executed_steps` in one query
|
|
10
|
+
* Changed: the `from`/`to` filters and the series' starts use started_at; `lepus-saga ~> 0.3.1` is required
|
|
11
|
+
* Changed: dashboard indexes folded into the install migration (pre-release): migration 001 indexes `saga_executions (started_at, id)`, `(duration_ms, id)`, `(finished_at)`, `(saga_name, started_at)`, `(status, started_at)`, `(partition, started_at)` and the resource index on `(resource_type, resource_id, started_at)`; the created_at indexes and migration 002 are gone, so a database that ran 002 needs its tables recreated
|
|
12
|
+
|
|
7
13
|
## 0.3.0 - Unreleased
|
|
8
14
|
* Added: brand mark and README logo
|
|
9
15
|
* Added: `Lepus::Saga::Stores::Sequel`, selected with `Lepus::Saga.configure { |c| c.store = :sequel }`, and `Lepus::Saga::Sequel.db=` to bind the models to a database other than `Sequel::Model.db`
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Lepus::Saga::Sequel.migrate(DB, target: 0) # drop them
|
|
|
27
27
|
|
|
28
28
|
The version is tracked in its own `lepus_saga_schema_info` table, so it never collides with your app's `schema_info` or `schema_migrations`. To keep everything in one migration history instead, copy the files from `lib/lepus/saga/sequel/migrations` into your own migrations directory.
|
|
29
29
|
|
|
30
|
-
Ids are UUIDv7 strings generated in Ruby, the context columns are `json` and `failure_reason` is `text` (a step's error message can outgrow `VARCHAR(255)`, and MySQL/MariaDB strict mode would reject the write that fails the saga). The table, column and index names are the ones the lepus-saga-active_record generator creates, so an app can move between the two gems on the same tables. Column types can differ slightly: Sequel creates unsized strings as `text` on PostgreSQL and `duration_ms` as a double-precision float on MySQL/MariaDB.
|
|
30
|
+
Ids are UUIDv7 strings generated in Ruby, the context columns are `json` and `failure_reason` is `text` (a step's error message can outgrow `VARCHAR(255)`, and MySQL/MariaDB strict mode would reject the write that fails the saga). The table, column and index names are the ones the lepus-saga-active_record generator creates, so an app can move between the two gems on the same tables. Column types can differ slightly: Sequel creates unsized strings as `text` on PostgreSQL and the step attempts' `duration_ms` as a double-precision float on MySQL/MariaDB. `saga_executions.duration_ms` is a `bigint` the engine writes when an execution ends. Besides the resource, progress and attempt indexes the engine uses, the migration indexes what the console sorts and filters on: `saga_executions (started_at, id)`, `(duration_ms, id)`, `(finished_at)`, `(saga_name, started_at)`, `(status, started_at)` and `(partition, started_at)`, and `saga_step_attempts (started_at)` and `(error_class, started_at)`.
|
|
31
31
|
|
|
32
32
|
## Configuration
|
|
33
33
|
|
|
@@ -106,11 +106,14 @@ Lepus::Saga::Sequel::Execution.exclude(id: pinned_ids).prune(finished_before: Ti
|
|
|
106
106
|
| `save_execution`, `save_step_attempt` | `save`, which writes every column, so a context changed in place is persisted |
|
|
107
107
|
| `increment_resume_count` | `UPDATE ... SET resume_count = resume_count + 1, updated_at = ?`, then reads the stored value back |
|
|
108
108
|
| `create_step_attempt` | `create`; `Sequel::UniqueConstraintViolation` from `idx_saga_step_attempts_dedup` becomes `Lepus::Saga::DuplicateAttemptError` |
|
|
109
|
-
| `stalled_executions(before:)` | `in_progress.where(last_progress_at < before).order(:last_progress_at).all` |
|
|
109
|
+
| `stalled_executions(before:)` | `in_progress.where(last_progress_at < before).order(:last_progress_at, :id).all` |
|
|
110
|
+
| `executions_for_resource` | `for_resource(type, id).recent_first`: `ORDER BY started_at DESC, id DESC` on the resource index |
|
|
110
111
|
| attempt queries | `where(saga_execution_id:, step_name:, direction:, epoch:)` plus `count`/`empty?` |
|
|
111
|
-
| `list_executions` | Filters on the executions table, `
|
|
112
|
+
| `list_executions` | Filters on the executions table, ordered by `(started_at, id)`, `(duration_ms, id)` or `(finished_at, id)` in the requested direction. Paging is keyset: rows after `after` compare against its stored value in a subquery (`col <= (SELECT col ... WHERE id = ?) AND (col < (SELECT ...) OR id < ?)` descending, `>=` and `>` ascending); the bare bound lets the `(column, id)` index start at the cursor instead of reading every row before it. For a nullable sort it runs at most two queries, the rows with a value and then, when the page is not full, the rows without one by id, so both use the `(column, id)` index and nulls come last either way; the resource search is `ILIKE` with the LIKE wildcards escaped; the error filters are `id IN (SELECT saga_execution_id FROM saga_step_attempts WHERE status = 'failed' ...)` |
|
|
112
113
|
| `count_executions` | `group_and_count(...)`; grouping by partition also groups by saga name to resolve executions without one |
|
|
113
|
-
| `
|
|
114
|
+
| `finished_executions` | Base's: `count_executions` grouped by saga and status over `finished_from`/`finished_to`, then one `list_executions(sort: :finished_at)` per saga, on the `finished_at` index |
|
|
115
|
+
| `executed_steps` | One `SELECT DISTINCT saga_execution_id, step_name FROM saga_step_attempts WHERE saga_execution_id IN (...) AND direction = 'execute'` for every execution `/api/recent` lists |
|
|
116
|
+
| `execution_series` | Selects `started_at`, and `finished_at` and `status` of terminal executions, newest first and at most `max_rows` + 1; buckets them in Ruby |
|
|
114
117
|
| `step_latency` | Joins attempts to executions and selects the newest `max_rows` + 1 durations; percentiles in Ruby |
|
|
115
118
|
| `failure_summary` | `GROUP BY error_class, saga_name, step_name` with `COUNT(*)` and `COUNT(DISTINCT saga_execution_id)`, ordered by `COUNT(*)` and `MAX(finished_at)`, then one query per group for its latest failure |
|
|
116
119
|
| `saga_names` | `distinct.order(:saga_name).select_map(:saga_name)` |
|
|
@@ -129,11 +132,9 @@ The `many_to_one :execution` of a step attempt is not validated, because the for
|
|
|
129
132
|
|
|
130
133
|
## Existing tables
|
|
131
134
|
|
|
132
|
-
The
|
|
135
|
+
The migration uses `create_table?`, so it adopts tables that already exist (created by hand or by the lepus-saga-active_record generator). Note that rolling it back drops them.
|
|
133
136
|
|
|
134
|
-
Tables created before the
|
|
135
|
-
|
|
136
|
-
Tables created by hand (PostgreSQL `uuid` ids, `jsonb` context) work too: the models set UUIDv7 ids before insert and read the JSON columns whether the driver returns a String or a parsed value (the `pg_json` extension).
|
|
137
|
+
Tables created by hand (PostgreSQL `uuid` ids, `jsonb` context) work too: the models set UUIDv7 ids before insert and read the JSON columns whether the driver returns a String or a parsed value (the `pg_json` extension). Rows written with ids that are not time-ordered (e.g. random UUIDv4) list in time order, since the store sorts by started_at (or duration) and only breaks ties by id. Adopted tables keep their own columns and indexes: add `saga_executions.duration_ms` (`Bignum`) and the indexes listed above in a migration of your own, and backfill the duration of finished executions.
|
|
137
138
|
|
|
138
139
|
## Architecture
|
|
139
140
|
|
|
@@ -15,7 +15,7 @@ module Lepus
|
|
|
15
15
|
dataset_module do
|
|
16
16
|
where :in_progress, status: IN_PROGRESS_STATUSES
|
|
17
17
|
where :completed, status: "completed"
|
|
18
|
-
order :recent_first, ::Sequel.desc(:
|
|
18
|
+
order :recent_first, ::Sequel.desc(:started_at), ::Sequel.desc(:id)
|
|
19
19
|
|
|
20
20
|
def for_resource(type, id)
|
|
21
21
|
where(resource_type: type, resource_id: id.to_s)
|
|
@@ -17,18 +17,21 @@ Sequel.migration do
|
|
|
17
17
|
String :partition
|
|
18
18
|
DateTime :started_at, null: false
|
|
19
19
|
DateTime :finished_at
|
|
20
|
+
Bignum :duration_ms
|
|
20
21
|
DateTime :last_progress_at, null: false
|
|
21
22
|
Integer :resume_count, null: false, default: 0
|
|
22
23
|
DateTime :created_at, null: false
|
|
23
24
|
DateTime :updated_at, null: false
|
|
24
25
|
|
|
25
|
-
index %i[resource_type resource_id
|
|
26
|
+
index %i[resource_type resource_id started_at], name: :idx_saga_executions_on_resource
|
|
26
27
|
index %i[status updated_at], name: :index_saga_executions_on_status_and_updated_at
|
|
27
28
|
index %i[status last_progress_at], name: :index_saga_executions_on_status_and_progress
|
|
28
|
-
index %i[
|
|
29
|
-
index %i[
|
|
30
|
-
index %i[status created_at], name: :index_saga_executions_on_status_and_created_at
|
|
29
|
+
index %i[started_at id], name: :index_saga_executions_on_started_at_and_id
|
|
30
|
+
index %i[duration_ms id], name: :index_saga_executions_on_duration_ms_and_id
|
|
31
31
|
index :finished_at, name: :index_saga_executions_on_finished_at
|
|
32
|
+
index %i[partition started_at], name: :index_saga_executions_on_partition_and_started_at
|
|
33
|
+
index %i[saga_name started_at], name: :index_saga_executions_on_saga_name_and_started_at
|
|
34
|
+
index %i[status started_at], name: :index_saga_executions_on_status_and_started_at
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
create_table?(:saga_step_attempts) do
|
|
@@ -61,7 +61,7 @@ module Lepus
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def stalled_executions(before:)
|
|
64
|
-
execution_class.in_progress.where(::Sequel[:last_progress_at] < before).order(:last_progress_at).all
|
|
64
|
+
execution_class.in_progress.where(::Sequel[:last_progress_at] < before).order(:last_progress_at, :id).all
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def executions_for_resource(resource_type, resource_id)
|
|
@@ -83,6 +83,15 @@ module Lepus
|
|
|
83
83
|
attempts_of(execution).chronological.all
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
def executed_steps(executions)
|
|
87
|
+
steps = executions.to_h { |execution| [execution.id, []] }
|
|
88
|
+
return steps if steps.empty?
|
|
89
|
+
|
|
90
|
+
step_attempt_class.where(saga_execution_id: steps.keys, direction: "execute").distinct
|
|
91
|
+
.select_map(%i[saga_execution_id step_name]).each { |id, step| steps[id] << step }
|
|
92
|
+
steps
|
|
93
|
+
end
|
|
94
|
+
|
|
86
95
|
def count_step_attempts(execution, step_name:, direction:, epoch:)
|
|
87
96
|
attempts_of(execution).where(step_name: step_name, direction: direction, epoch: epoch).count
|
|
88
97
|
end
|
|
@@ -107,10 +116,29 @@ module Lepus
|
|
|
107
116
|
|
|
108
117
|
def queries? = true
|
|
109
118
|
|
|
110
|
-
|
|
119
|
+
# Keyset paging in up to two index-friendly queries: the executions
|
|
120
|
+
# with a value in (value, id) order, then those without one by id.
|
|
121
|
+
def list_executions(sort: :started_at, direction: :desc, after: nil, limit: 50, **filters)
|
|
122
|
+
column, descending, nullable = list_order(sort, direction, filters)
|
|
111
123
|
scope = matching_executions(**filters)
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
order = ->(name) { descending ? ::Sequel.desc(name) : ::Sequel.asc(name) }
|
|
125
|
+
if after
|
|
126
|
+
cursor = execution_class.where(id: after).get([:id, column])
|
|
127
|
+
return [] unless cursor
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
in_nulls = cursor && cursor.last.nil?
|
|
131
|
+
rows = []
|
|
132
|
+
unless in_nulls
|
|
133
|
+
valued = nullable ? scope.exclude(column => nil) : scope
|
|
134
|
+
valued = valued.where(beyond(column, after, descending)) if cursor
|
|
135
|
+
rows = valued.order(order.call(column), order.call(:id)).limit(limit).all
|
|
136
|
+
end
|
|
137
|
+
return rows unless nullable && rows.size < limit
|
|
138
|
+
|
|
139
|
+
unvalued = scope.where(column => nil)
|
|
140
|
+
unvalued = unvalued.where(descending ? ::Sequel[:id] < after : ::Sequel[:id] > after) if in_nulls
|
|
141
|
+
rows + unvalued.order(order.call(:id)).limit(limit - rows.size).all
|
|
114
142
|
end
|
|
115
143
|
|
|
116
144
|
def count_executions(group_by:, **filters)
|
|
@@ -124,8 +152,8 @@ module Lepus
|
|
|
124
152
|
seconds = series_bucket(from, to, bucket)
|
|
125
153
|
scope = matching_executions(saga_name: saga_name)
|
|
126
154
|
started, started_truncated = take_rows(
|
|
127
|
-
in_window(scope, :
|
|
128
|
-
.order(::Sequel.desc(:
|
|
155
|
+
in_window(scope, :started_at, from, to)
|
|
156
|
+
.order(::Sequel.desc(:started_at)).limit(max_rows + 1).select_map(:started_at),
|
|
129
157
|
max_rows
|
|
130
158
|
)
|
|
131
159
|
finished, finished_truncated = take_rows(
|
|
@@ -211,8 +239,24 @@ module Lepus
|
|
|
211
239
|
scope
|
|
212
240
|
end
|
|
213
241
|
|
|
242
|
+
# Rows strictly after the execution `after` in (column, id) order,
|
|
243
|
+
# comparing against its stored value in a subquery so no precision is
|
|
244
|
+
# lost on the way through Ruby. The bare bound on the column lets the
|
|
245
|
+
# (column, id) index start at the cursor instead of filtering every
|
|
246
|
+
# row before it.
|
|
247
|
+
def beyond(column, after, descending)
|
|
248
|
+
value = execution_class.where(id: after).select(column)
|
|
249
|
+
strict, bound = descending ? %i[< <=] : %i[> >=]
|
|
250
|
+
compare = ->(operator, left, right) { ::Sequel::SQL::BooleanExpression.new(operator, left, right) }
|
|
251
|
+
::Sequel.&(
|
|
252
|
+
compare.call(bound, ::Sequel[column], value),
|
|
253
|
+
::Sequel.|(compare.call(strict, ::Sequel[column], value), compare.call(strict, ::Sequel[:id], after))
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
214
257
|
def matching_executions(saga_name: nil, status: nil, current_step: nil, partition: nil, resource: nil, from: nil, to: nil,
|
|
215
|
-
progress_from: nil, progress_to: nil,
|
|
258
|
+
progress_from: nil, progress_to: nil, finished_from: nil, finished_to: nil, error_class: nil, error_step: nil, error_from: nil,
|
|
259
|
+
error_to: nil)
|
|
216
260
|
sagas = filter_values(saga_name)
|
|
217
261
|
statuses = filter_values(status)
|
|
218
262
|
steps = filter_values(current_step)
|
|
@@ -220,8 +264,9 @@ module Lepus
|
|
|
220
264
|
scope = scope.where(saga_name: sagas) if sagas
|
|
221
265
|
scope = scope.where(status: statuses) if statuses
|
|
222
266
|
scope = scope.where(current_step: steps) if steps
|
|
223
|
-
scope = in_window(scope, :
|
|
267
|
+
scope = in_window(scope, :started_at, from, to)
|
|
224
268
|
scope = in_window(scope, :last_progress_at, progress_from, progress_to)
|
|
269
|
+
scope = in_window(scope, :finished_at, finished_from, finished_to)
|
|
225
270
|
failing = failed_attempts(filter_values(error_class), filter_values(error_step), error_from, error_to)
|
|
226
271
|
scope = scope.where(id: failing.select(:saga_execution_id)) if failing
|
|
227
272
|
scope = scope.where(in_partition(partition.to_s)) unless blank?(partition)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lepus-saga-sequel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcos G. Zimmermann
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.3.
|
|
18
|
+
version: 0.3.1
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.3.
|
|
25
|
+
version: 0.3.1
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: sequel
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -174,7 +174,6 @@ files:
|
|
|
174
174
|
- lib/lepus/saga/sequel.rb
|
|
175
175
|
- lib/lepus/saga/sequel/execution.rb
|
|
176
176
|
- lib/lepus/saga/sequel/migrations/001_create_lepus_saga_tables.rb
|
|
177
|
-
- lib/lepus/saga/sequel/migrations/002_add_lepus_saga_dashboard_indexes.rb
|
|
178
177
|
- lib/lepus/saga/sequel/record.rb
|
|
179
178
|
- lib/lepus/saga/sequel/step_attempt.rb
|
|
180
179
|
- lib/lepus/saga/sequel/version.rb
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# The indexes the query methods filter on, for tables created before 001
|
|
4
|
-
# declared them. A no-op on tables created by 001.
|
|
5
|
-
Sequel.migration do
|
|
6
|
-
dashboard_indexes = {
|
|
7
|
-
saga_executions: {
|
|
8
|
-
index_saga_executions_on_saga_name_and_created_at: %i[saga_name created_at],
|
|
9
|
-
index_saga_executions_on_status_and_created_at: %i[status created_at],
|
|
10
|
-
index_saga_executions_on_finished_at: %i[finished_at]
|
|
11
|
-
},
|
|
12
|
-
saga_step_attempts: {
|
|
13
|
-
index_saga_step_attempts_on_started_at: %i[started_at],
|
|
14
|
-
index_saga_step_attempts_on_error_class_and_started_at: %i[error_class started_at]
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
up do
|
|
19
|
-
dashboard_indexes.each do |table, named|
|
|
20
|
-
present = indexes(table).keys
|
|
21
|
-
named.each { |name, columns| add_index(table, columns, name: name) unless present.include?(name) }
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
down do
|
|
26
|
-
dashboard_indexes.each do |table, named|
|
|
27
|
-
present = indexes(table).keys
|
|
28
|
-
named.each { |name, columns| drop_index(table, columns, name: name) if present.include?(name) }
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|