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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 688935ea658850abc331020a8bef9b9a11a940c99e19a65c054fb593871406b9
4
- data.tar.gz: 69feb77e326f97a3a344c29b79f6ebb80f254906239a7d3ff99a70bbd38df562
3
+ metadata.gz: 1ab2a2679b384739bc0e66d34a24999eea651f6e4d2f8d1cef2de9cc1c5caed1
4
+ data.tar.gz: da5a456ca77c48694aa8ba157836850a80cf4cf996225b42ed1b1e4c6217aa72
5
5
  SHA512:
6
- metadata.gz: 36b993b968e9781e5eb1cedc84bb52fa2329bfa5b48f6e81b9f941cf8a6b3b46890108eaa68d758bd70451101c69c82969f03cdf52e51622d02292acc5eeacf1
7
- data.tar.gz: df9f85d83bdc581a1db3da45616a19e2c8b2f4fedcd636bff5322aae401f8505f062811fb71b58b84e6498f842754b0100af1720a0d312adaaa804dd15dced45
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, `order(Sequel.desc(:id))` with `id < before_id` for keyset paging; 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
+ | `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
- | `execution_series` | Selects `created_at`, and `finished_at` and `status` of terminal executions, newest first and at most `max_rows` + 1; buckets them in Ruby |
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 first 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.
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 indexes the query methods filter on existed lack them. The second migration adds `saga_executions (saga_name, created_at)`, `(status, created_at)` and `(finished_at)`, and `saga_step_attempts (started_at)` and `(error_class, started_at)`, skipping any that already exist.
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(:created_at)
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 created_at], name: :idx_saga_executions_on_resource
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[partition created_at], name: :index_saga_executions_on_partition_and_created_at
29
- index %i[saga_name created_at], name: :index_saga_executions_on_saga_name_and_created_at
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
@@ -3,7 +3,7 @@
3
3
  module Lepus
4
4
  module Saga
5
5
  module Sequel
6
- VERSION = "0.3.0"
6
+ VERSION = "0.3.1"
7
7
  end
8
8
  end
9
9
  end
@@ -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
- def list_executions(before_id: nil, limit: 50, **filters)
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
- scope = scope.where(::Sequel[:id] < before_id) if before_id
113
- scope.order(::Sequel.desc(:id)).limit(limit).all
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, :created_at, from, to)
128
- .order(::Sequel.desc(:created_at)).limit(max_rows + 1).select_map(:created_at),
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, error_class: nil, error_step: nil, error_from: nil, error_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, :created_at, from, to)
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.0
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.0
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.0
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