lepus-saga-active_record 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: 72048dd94435e22305e296501e08f2d19234da3081fbf3c4c2fd75afdecd79f9
4
- data.tar.gz: df6d77f4f98b6596449de33c4fba15a479d74e5c285f8cc406776727e7d72328
3
+ metadata.gz: 31354b38e13fe1a57459dc0c215b05ae8c80f7437b4545269fc1c9a53dd801e1
4
+ data.tar.gz: 61413e70d7aebca66b160eb7e986fe94ed0d764276ecfe3c638a1a25a723e03e
5
5
  SHA512:
6
- metadata.gz: '09d4fbe4123daf7310d7a387f63db9f0e9e4b4055eff5035a3bb8c7285a547d53fecc7850dab249d780fa72aa7d19fb3d8e61382b77e1f01a00c5f9b6bfc404d'
7
- data.tar.gz: 9ace56afa8214880ae7d97b39347e6cefdd5906095befb3bbd01064b5e856fcf41555f985b8792ab7f65322ea1f4d4f218d9df637281e7e7c15950676838fc34
6
+ metadata.gz: f5f25f07e133616a20952014f5c3541b01525fe689c99f35a4423f831096a5cecd6ee5d1526504f623bc7f9191f1b230614d65fec703d59c279cb8eff66abee2
7
+ data.tar.gz: 6f6e72dd03fedf4b922c9187441fa2e6a2de0f22ec6d4bfc5c79e4f2a00abde2b354d0ae08a29372c02a33a0b58b75b4a0278072a8ea49483a73f62fb68711eb
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): `lepus:saga:install` 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 the `lepus:saga:dashboard_indexes` generator are gone
12
+
7
13
  ## 0.3.0 - Unreleased
8
14
  * Added: brand mark and README logo
9
15
  * Added: `Lepus::Saga::Stores::ActiveRecord`, selected with `Lepus::Saga.configure { |c| c.store = :active_record }`
data/README.md CHANGED
@@ -22,7 +22,7 @@ bin/rails generate lepus:saga:install
22
22
  bin/rails db:migrate
23
23
  ```
24
24
 
25
- The generator writes one migration that creates both tables. 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).
25
+ The generator writes one migration that creates both tables and their indexes. 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). `saga_executions.duration_ms` is a `bigint` the engine writes when an execution ends. Besides the resource, progress and attempt indexes the engine uses, it 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)`.
26
26
 
27
27
  ```ruby
28
28
  # config/initializers/lepus_saga.rb
@@ -68,11 +68,14 @@ Lepus::Saga::ActiveRecord::Execution.where.not(id: pinned_ids).prune(finished_be
68
68
  | `with_locked_execution(id)` | `transaction { Execution.lock.find_by(id:) }`, one query |
69
69
  | `increment_resume_count` | `update_counters(id, resume_count: 1, touch: true)`, then reads the stored value back |
70
70
  | `create_step_attempt` | `create!`; `ActiveRecord::RecordNotUnique` from `idx_saga_step_attempts_dedup` becomes `Lepus::Saga::DuplicateAttemptError` |
71
- | `stalled_executions(before:)` | `in_progress.where(last_progress_at: ...before).order(:last_progress_at)` |
71
+ | `stalled_executions(before:)` | `in_progress.where(last_progress_at: ...before).order(:last_progress_at, :id)` |
72
+ | `executions_for_resource` | `for_resource(type, id).recent_first`: `ORDER BY started_at DESC, id DESC` on the resource index |
72
73
  | attempt queries | `where(saga_execution_id:, step_name:, direction:, epoch:)` plus `count`/`exists?` |
73
- | `list_executions` | Filters on the executions table, `order(id: :desc)` with `id < before_id` for keyset paging; the resource search is `LOWER(column) LIKE ? ESCAPE '!'`; the error filters are `id IN (SELECT saga_execution_id FROM saga_step_attempts WHERE status = 'failed' ...)` |
74
+ | `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 `LOWER(column) LIKE ? ESCAPE '!'`; the error filters are `id IN (SELECT saga_execution_id FROM saga_step_attempts WHERE status = 'failed' ...)` |
74
75
  | `count_executions` | `group(...).count`; grouping by partition also groups by saga name to resolve executions without one |
75
- | `execution_series` | Plucks `created_at`, and `finished_at` and `status` of terminal executions, newest first and at most `max_rows` + 1; buckets them in Ruby |
76
+ | `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 |
77
+ | `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 |
78
+ | `execution_series` | Plucks `started_at`, and `finished_at` and `status` of terminal executions, newest first and at most `max_rows` + 1; buckets them in Ruby |
76
79
  | `step_latency` | Joins attempts to executions and plucks the newest `max_rows` + 1 durations; percentiles in Ruby |
77
80
  | `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 |
78
81
  | `saga_names` | `distinct.order(:saga_name).pluck(:saga_name)` |
@@ -89,16 +92,23 @@ The `belongs_to :execution` of a step attempt is optional, because the foreign k
89
92
 
90
93
  ## Existing tables
91
94
 
92
- Tables created by hand or before the generator (PostgreSQL `uuid` ids with `gen_random_uuid()` defaults, `jsonb` context, a `lock_version` column) work unchanged: the models set UUIDv7 ids before insert, read `jsonb` through the `json` attribute type and ignore `lock_version`.
95
+ Tables created by hand or before the generator (PostgreSQL `uuid` ids with `gen_random_uuid()` defaults, `jsonb` context, a `lock_version` column) work unchanged: the models set UUIDv7 ids before insert, read `jsonb` through the `json` attribute type and ignore `lock_version`. 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.
93
96
 
94
- Tables created before the install migration indexed the query columns lack the indexes the query methods filter on. Add them with:
97
+ Such tables, and tables created by the 0.3.0 install generator, lack the `duration_ms` column and some of the indexes the install migration creates. Add them in a migration of your own; `if_not_exists: true` skips the indexes a table already has:
95
98
 
96
- ```bash
97
- bin/rails generate lepus:saga:dashboard_indexes
98
- bin/rails db:migrate
99
+ ```ruby
100
+ add_column :saga_executions, :duration_ms, :bigint, if_not_exists: true
101
+ add_index :saga_executions, [:started_at, :id], if_not_exists: true
102
+ add_index :saga_executions, [:duration_ms, :id], if_not_exists: true
103
+ add_index :saga_executions, :finished_at, if_not_exists: true
104
+ add_index :saga_executions, [:saga_name, :started_at], if_not_exists: true
105
+ add_index :saga_executions, [:status, :started_at], if_not_exists: true
106
+ add_index :saga_executions, [:partition, :started_at], if_not_exists: true
107
+ add_index :saga_step_attempts, :started_at, if_not_exists: true
108
+ add_index :saga_step_attempts, [:error_class, :started_at], if_not_exists: true
99
109
  ```
100
110
 
101
- The 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.
111
+ and backfill the duration of finished executions, for example with `Lepus::Saga::ActiveRecord::Execution.where.not(finished_at: nil).find_each { |execution| execution.update_columns(duration_ms: ((execution.finished_at - execution.started_at) * 1000).round) }`. The `created_at` indexes of 0.3.0 (`[:saga_name, :created_at]`, `[:status, :created_at]`, `[:partition, :created_at]`) are no longer used and can be dropped.
102
112
 
103
113
  ## Architecture
104
114
 
@@ -112,7 +122,6 @@ StepAttemptBehaviour <─── Lepus::Saga::ActiveRecord::StepAttempt
112
122
  "a lepus saga engine" ───> (contract and full engine on every database)
113
123
  "a lepus saga query store" ───>
114
124
  lib/generators/lepus/saga/install
115
- lib/generators/lepus/saga/dashboard_indexes
116
125
  ```
117
126
 
118
127
  This gem is also the reference for writing another adapter (Sequel, ROM, a key-value store):
@@ -14,19 +14,22 @@ class CreateLepusSagaTables < ActiveRecord::Migration<%= migration_version %>
14
14
  t.string :partition
15
15
  t.datetime :started_at, null: false
16
16
  t.datetime :finished_at
17
+ t.bigint :duration_ms
17
18
  t.datetime :last_progress_at, null: false
18
19
  t.integer :resume_count, null: false, default: 0
19
20
  t.timestamps
20
21
  end
21
- add_index :saga_executions, [:resource_type, :resource_id, :created_at],
22
+ add_index :saga_executions, [:resource_type, :resource_id, :started_at],
22
23
  name: "idx_saga_executions_on_resource"
23
24
  add_index :saga_executions, [:status, :updated_at]
24
25
  add_index :saga_executions, [:status, :last_progress_at],
25
26
  name: "index_saga_executions_on_status_and_progress"
26
- add_index :saga_executions, [:partition, :created_at]
27
- add_index :saga_executions, [:saga_name, :created_at]
28
- add_index :saga_executions, [:status, :created_at]
27
+ add_index :saga_executions, [:started_at, :id]
28
+ add_index :saga_executions, [:duration_ms, :id]
29
29
  add_index :saga_executions, :finished_at
30
+ add_index :saga_executions, [:partition, :started_at]
31
+ add_index :saga_executions, [:saga_name, :started_at]
32
+ add_index :saga_executions, [:status, :started_at]
30
33
 
31
34
  create_table :saga_step_attempts, id: :string, limit: 36 do |t|
32
35
  t.references :saga_execution, type: :string, limit: 36, null: false, foreign_key: true
@@ -30,7 +30,7 @@ module Lepus
30
30
  scope :for_resource, ->(type, id) { where(resource_type: type, resource_id: id) }
31
31
  scope :in_progress, -> { where(status: IN_PROGRESS_STATUSES) }
32
32
  scope :completed, -> { where(status: "completed") }
33
- scope :recent_first, -> { order(created_at: :desc) }
33
+ scope :recent_first, -> { order(started_at: :desc, id: :desc) }
34
34
  # Deliberately not `updated_at`: a re-publish, a lock or an attempt
35
35
  # opened and abandoned all touch the row while the cursor stands
36
36
  # still, so a wedged execution would keep resetting its own stall clock.
@@ -3,7 +3,7 @@
3
3
  module Lepus
4
4
  module Saga
5
5
  module ActiveRecord
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(last_progress_at: ...before).order(:last_progress_at)
64
+ execution_class.in_progress.where(last_progress_at: ...before).order(:last_progress_at, :id)
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
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
+ .pluck(: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
@@ -104,10 +113,29 @@ module Lepus
104
113
 
105
114
  def queries? = true
106
115
 
107
- def list_executions(before_id: nil, limit: 50, **filters)
116
+ # Keyset paging in up to two index-friendly queries: the executions
117
+ # with a value in (value, id) order, then those without one by id.
118
+ def list_executions(sort: :started_at, direction: :desc, after: nil, limit: 50, **filters)
119
+ column, descending, nullable = list_order(sort, direction, filters)
108
120
  scope = matching_executions(**filters)
109
- scope = scope.where(id: ...before_id) if before_id
110
- scope.order(id: :desc).limit(limit).to_a
121
+ order = descending ? :desc : :asc
122
+ if after
123
+ cursor = execution_class.where(id: after).pluck(:id, column).first
124
+ return [] unless cursor
125
+ end
126
+
127
+ in_nulls = cursor && cursor.last.nil?
128
+ rows = []
129
+ unless in_nulls
130
+ valued = nullable ? scope.where.not(column => nil) : scope
131
+ valued = valued.where(beyond(column, after, descending)) if cursor
132
+ rows = valued.order(column => order, :id => order).limit(limit).to_a
133
+ end
134
+ return rows unless nullable && rows.size < limit
135
+
136
+ unvalued = scope.where(column => nil)
137
+ unvalued = unvalued.where(execution_class.arel_table[:id].public_send(descending ? :lt : :gt, after)) if in_nulls
138
+ rows + unvalued.order(id: order).limit(limit - rows.size).to_a
111
139
  end
112
140
 
113
141
  def count_executions(group_by:, **filters)
@@ -122,7 +150,7 @@ module Lepus
122
150
  seconds = series_bucket(from, to, bucket)
123
151
  scope = matching_executions(saga_name: saga_name)
124
152
  started, started_truncated = take_rows(
125
- scope.where(created_at: from...to).order(created_at: :desc).limit(max_rows + 1).pluck(:created_at),
153
+ scope.where(started_at: from...to).order(started_at: :desc).limit(max_rows + 1).pluck(:started_at),
126
154
  max_rows
127
155
  )
128
156
  finished, finished_truncated = take_rows(
@@ -176,8 +204,22 @@ module Lepus
176
204
  step_attempt_class.where(saga_execution_id: execution.id)
177
205
  end
178
206
 
207
+ # Rows strictly after the execution `after` in (column, id) order,
208
+ # comparing against its stored value in a subquery so no precision is
209
+ # lost on the way through Ruby. The bare bound on the column lets the
210
+ # (column, id) index start at the cursor instead of filtering every
211
+ # row before it.
212
+ def beyond(column, after, descending)
213
+ table = execution_class.arel_table
214
+ value = execution_class.where(id: after).select(column).arel
215
+ strict, bound = descending ? %i[lt lteq] : %i[gt gteq]
216
+ table[column].public_send(bound, value)
217
+ .and(table[column].public_send(strict, value).or(table[:id].public_send(strict, after)))
218
+ end
219
+
179
220
  def matching_executions(saga_name: nil, status: nil, current_step: nil, partition: nil, resource: nil, from: nil, to: nil,
180
- progress_from: nil, progress_to: nil, error_class: nil, error_step: nil, error_from: nil, error_to: nil)
221
+ progress_from: nil, progress_to: nil, finished_from: nil, finished_to: nil, error_class: nil, error_step: nil, error_from: nil,
222
+ error_to: nil)
181
223
  sagas = filter_values(saga_name)
182
224
  statuses = filter_values(status)
183
225
  steps = filter_values(current_step)
@@ -185,10 +227,12 @@ module Lepus
185
227
  scope = scope.where(saga_name: sagas) if sagas
186
228
  scope = scope.where(status: statuses) if statuses
187
229
  scope = scope.where(current_step: steps) if steps
188
- scope = scope.where(created_at: from..) if from
189
- scope = scope.where(created_at: ...to) if to
230
+ scope = scope.where(started_at: from..) if from
231
+ scope = scope.where(started_at: ...to) if to
190
232
  scope = scope.where(last_progress_at: progress_from..) if progress_from
191
233
  scope = scope.where(last_progress_at: ...progress_to) if progress_to
234
+ scope = scope.where(finished_at: finished_from..) if finished_from
235
+ scope = scope.where(finished_at: ...finished_to) if finished_to
192
236
  failing = failed_attempts(filter_values(error_class), filter_values(error_step), error_from, error_to)
193
237
  scope = scope.where(id: failing.select(:saga_execution_id)) if failing
194
238
  scope = scope.and(in_partition(partition.to_s)) if partition.present?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lepus-saga-active_record
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: activerecord
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -199,8 +199,6 @@ files:
199
199
  - docs/brand/mark-small-on-light.svg
200
200
  - docs/brand/mark-small.svg
201
201
  - docs/brand/mark.svg
202
- - lib/generators/lepus/saga/dashboard_indexes/dashboard_indexes_generator.rb
203
- - lib/generators/lepus/saga/dashboard_indexes/templates/add_lepus_saga_dashboard_indexes.rb.erb
204
202
  - lib/generators/lepus/saga/install/install_generator.rb
205
203
  - lib/generators/lepus/saga/install/templates/create_lepus_saga_tables.rb.erb
206
204
  - lib/lepus/saga/active_record.rb
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module Lepus
7
- module Saga
8
- class DashboardIndexesGenerator < Rails::Generators::Base
9
- include ::ActiveRecord::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- desc "Adds the indexes the store's query methods use to saga tables created before they were in lepus:saga:install"
14
-
15
- def create_migration_file
16
- migration_template "add_lepus_saga_dashboard_indexes.rb.erb", File.join(db_migrate_path, "add_lepus_saga_dashboard_indexes.rb")
17
- end
18
-
19
- private
20
-
21
- def migration_version
22
- "[#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}]"
23
- end
24
- end
25
- end
26
- end
@@ -1,17 +0,0 @@
1
- class AddLepusSagaDashboardIndexes < ActiveRecord::Migration<%= migration_version %>
2
- def up
3
- add_index :saga_executions, [:saga_name, :created_at], if_not_exists: true
4
- add_index :saga_executions, [:status, :created_at], if_not_exists: true
5
- add_index :saga_executions, :finished_at, if_not_exists: true
6
- add_index :saga_step_attempts, :started_at, if_not_exists: true
7
- add_index :saga_step_attempts, [:error_class, :started_at], if_not_exists: true
8
- end
9
-
10
- def down
11
- remove_index :saga_step_attempts, [:error_class, :started_at], if_exists: true
12
- remove_index :saga_step_attempts, :started_at, if_exists: true
13
- remove_index :saga_executions, :finished_at, if_exists: true
14
- remove_index :saga_executions, [:status, :created_at], if_exists: true
15
- remove_index :saga_executions, [:saga_name, :created_at], if_exists: true
16
- end
17
- end