hekenga 2.0.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f21c3c1cb0e45c3b9eb2627fc960d032ff981e01b239dc69c02d2aebc1f7b539
4
- data.tar.gz: 62fbbd8a65bae8bacc537bcc40cc4a3960b795ebb19a13c34b8ebd539eb102d0
3
+ metadata.gz: d81f0def52813a113005c985703355b9d207a9fb91acae8e4948d01dc43f7b33
4
+ data.tar.gz: f462545e892ababf6a251dd5d366ef59cfc0f799b1ba3456102ee0483cab652d
5
5
  SHA512:
6
- metadata.gz: 6317b298a05085564cfaaee1beef6b5749e83f8bc877538b448db66b50c3dde50f0380a5fa7f178dff7feca6840564c486f263891a838d4ed51617b7ff4f8698
7
- data.tar.gz: 12ba1c564acca2f7d43c80a384a0767b00592abf5126d0016a0bf7cadf610e6d342890cd54597a602654e8449491135a3dda488bb7c1c8aaeb72d035bdc7c5d6
6
+ metadata.gz: 62e018a5ad375a280f641847c3c85c18b190ff7ef83d558a53210b062a80eecb183168af77baac2d2a7f2b7d5d16438b2f6057faaaa7a52e410f952a46673d69
7
+ data.tar.gz: 10b34cb83cf1f8d643c3cd11f06389ae6911d84c6afa7ff7901a681a735a83bb24108178a162ba0005ef7ed1992c54448ac3eff4479498533505a7ab65a0a71e
data/CHANGELOG.md CHANGED
@@ -1,8 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.2.0
4
+
5
+ - Added a `hekenga watch <path_or_pkey>` CLI command to attach to a running
6
+ migration and report its status periodically. Accepts an `--interval`
7
+ option (defaults to `Hekenga.config.report_sleep`).
8
+ - Added a `hekenga failures <path_or_pkey>` CLI command to print all failed and
9
+ invalid document IDs across a migration's document tasks.
10
+ - Document tasks now support a `skip_validation!` option to write documents
11
+ without running `.valid?`.
12
+
13
+ ## v2.1.0
14
+
15
+ - `per_document` task `scope` will no longer let you specify `.only` or
16
+ `.without` as it could potentially cause data loss.
17
+ - `per_document` task `scope` now correctly works with `.includes` even
18
+ in parallel execution mode.
19
+
3
20
  ## v2.0.0
4
21
 
5
- - `Hekenga::Iterator` has been replaced by `Hekenga::IdIterator`. If any
22
+ - (breaking) `Hekenga::Iterator` has been replaced by `Hekenga::IdIterator`. If any
6
23
  selector or sort is set on a document task migration scope, it no longer forces an
7
24
  ascending ID sort. This should help to prevent index misses, though there is a
8
25
  tradeoff that documents being concurrently updated may be skipped or
data/CLAUDE.md ADDED
@@ -0,0 +1,60 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Hekenga is a Ruby gem providing a migration framework for MongoDB (via Mongoid). It supports sequential and parallel document processing via ActiveJob, with error recovery, validation tracking, and a Thor-based CLI.
8
+
9
+ ## Common Commands
10
+
11
+ ```bash
12
+ # Run full test suite (requires MongoDB - see docker-compose.yml)
13
+ rake spec
14
+
15
+ # Run a single spec file
16
+ rake spec SPEC=spec/hekenga/document_task_spec.rb
17
+
18
+ # Install gem locally
19
+ bundle exec rake install
20
+
21
+ # Interactive console with gem loaded
22
+ bin/console
23
+ ```
24
+
25
+ ## Architecture
26
+
27
+ ### Migration Flow
28
+
29
+ ```
30
+ Migration.perform! → MasterProcess.run! → launches tasks in threads
31
+ SimpleTask: executes up/down blocks directly
32
+ DocumentTask: iterates documents → batch → execute → write (sequential)
33
+ ParallelTask: splits into ID batches → enqueues ParallelJob per batch (via ActiveJob)
34
+ ```
35
+
36
+ ### Key Components
37
+
38
+ - **`Hekenga::Migration`** — main migration class, orchestrates tasks
39
+ - **`Hekenga::MasterProcess`** — launches tasks, manages execution/recovery/progress
40
+ - **`Hekenga::DSL::*`** — fluent DSL for defining migrations (`DSL::Migration`, `DSL::SimpleTask`, `DSL::DocumentTask`)
41
+ - **`Hekenga::DocumentTaskExecutor`** — core document processing: filter → up block → validate → write
42
+ - **`Hekenga::ParallelTask`** / **`Hekenga::ParallelJob`** — parallel execution via ActiveJob
43
+ - **`Hekenga::DocumentTaskRecord`** — Mongoid doc tracking parallel task progress
44
+ - **`Hekenga::Log`** — Mongoid doc tracking migration/task status (`:naught`, `:running`, `:complete`, `:failed`, `:skipped`)
45
+ - **`Hekenga::Failure::*`** — error/validation/write/cancelled failure tracking subclasses
46
+ - **`Hekenga::IdIterator`** / **`Hekenga::MongoidIterator`** — efficient document iteration for parallel vs sequential paths
47
+
48
+ ### Task Types
49
+
50
+ - **SimpleTask** — one-off up/down blocks, no document iteration
51
+ - **DocumentTask** — per-document processing with scope, filter, setup, up, down, after blocks; supports `parallel!`, `timeless!`, `always_write!`, `use_transaction!`, configurable write strategies (`:update` vs `:delete_then_insert`)
52
+
53
+ ### Configuration
54
+
55
+ Via `Hekenga.configure` block — sets migration directory and report frequency. Thread-safe registry tracks all migrations.
56
+
57
+ ## Dependencies
58
+
59
+ - **mongoid** (>= 6), **activejob** (>= 5), **thor** (1.2.1)
60
+ - Test: **rspec** (~> 3.0), **database_cleaner-mongoid** (~> 2.0), **pry**
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Hekenga
2
2
 
3
- An attempt at a migration framework for MongoDB that supports parallel document
4
- processing via ActiveJob, chained jobs and error recovery.
3
+ A migration framework for MongoDB (via Mongoid) that supports parallel document
4
+ processing via ActiveJob, chained jobs, and error recovery.
5
5
 
6
6
  ## Installation
7
7
 
@@ -19,13 +19,151 @@ Or install it yourself as:
19
19
 
20
20
  $ gem install hekenga
21
21
 
22
+ ## Configuration
23
+
24
+ ```ruby
25
+ Hekenga.configure do |config|
26
+ config.dir = ["db", "hekenga"] # where migration files live (relative to root)
27
+ config.root = Dir.pwd # application root
28
+ end
29
+ ```
30
+
31
+ Migrations are stored as Ruby files in the configured directory (default: `db/hekenga/`).
32
+
22
33
  ## Usage
23
34
 
24
- CLI instructions:
35
+ ### CLI
36
+
37
+ ```
38
+ $ hekenga help # Show all available commands
39
+ $ hekenga generate <description> # Generate a new migration scaffold
40
+ $ hekenga status # Show status of all migrations
41
+ $ hekenga run_all! # Run all pending migrations in date order
42
+ $ hekenga run! <path_or_pkey> # Run a specific migration
43
+ $ hekenga run! <path_or_pkey> --test # Dry run (no writes persisted)
44
+ $ hekenga run! <path_or_pkey> --clear # Clear logs before running
45
+ $ hekenga recover! <path_or_pkey> # Re-process failed/invalid records
46
+ $ hekenga watch <path_or_pkey> # Attach to a running migration, report status periodically
47
+ $ hekenga failures <path_or_pkey> # Print all failed/invalid document IDs for a migration
48
+ $ hekenga cancel # Cancel all active migrations
49
+ $ hekenga skip <path_or_pkey> # Mark a migration as skipped
50
+ $ hekenga clear! <path_or_pkey> # Remove all logs/failures for a migration
51
+ $ hekenga cleanup # Remove all failure logs
52
+ ```
53
+
54
+ ### Writing Migrations
55
+
56
+ Generate a migration scaffold:
57
+
58
+ $ hekenga generate "Add default role to users"
59
+
60
+ #### Simple Tasks
61
+
62
+ Simple tasks run arbitrary code once. Use `actual?` and `test?` to check execution mode.
63
+
64
+ ```ruby
65
+ Hekenga.migration do
66
+ description "Backfill analytics collection"
67
+ created "2024-01-15 10:00"
68
+
69
+ task "Create indexes" do
70
+ up do
71
+ Analytics.create_indexes if actual?
72
+ end
73
+ end
74
+ end
75
+ ```
76
+
77
+ #### Document Tasks
78
+
79
+ Document tasks iterate over a Mongoid scope and process each document in batches.
80
+
81
+ ```ruby
82
+ Hekenga.migration do
83
+ description "Normalize user emails"
84
+ created "2024-01-15 10:00"
85
+ batch_size 100 # default batch size for all tasks in this migration
86
+
87
+ per_document "Downcase emails" do
88
+ scope User.all
89
+
90
+ # Called once per batch; instance variables are shared with filter/up/after
91
+ setup do |docs|
92
+ @domain_map = ExternalService.load_domains
93
+ end
94
+
95
+ # Return false to skip a document
96
+ filter do |doc|
97
+ doc.email.present?
98
+ end
99
+
100
+ # Mutate the document in place — Hekenga handles persistence
101
+ up do |doc|
102
+ doc.email = doc.email.downcase
103
+ end
104
+
105
+ # Called once per batch with the successfully written documents
106
+ after do |docs|
107
+ AuditLog.record(docs.map(&:id))
108
+ end
109
+ end
110
+ end
111
+ ```
112
+
113
+ #### Document Task Options
114
+
115
+ ```ruby
116
+ per_document "Process records" do
117
+ scope MyModel.where(active: true)
118
+
119
+ parallel! # Process batches in parallel via ActiveJob
120
+ timeless! # Don't update Mongoid timestamps
121
+ always_write! # Write even if the document didn't change
122
+ skip_prepare! # Skip Mongoid callbacks on load
123
+ skip_validation! # Write documents without running Mongoid validations
124
+ use_transaction! # Wrap each batch in a MongoDB transaction
125
+ batch_size 50 # Override migration-level batch size
126
+ write_strategy :update # :update (default) or :delete_then_insert
127
+ cursor_timeout 86_400 # Max cursor lifetime in seconds (default: 1 day)
128
+
129
+ up do |doc|
130
+ doc.status = "migrated"
131
+ end
132
+ end
133
+ ```
134
+
135
+ ### Test Mode
136
+
137
+ Run a migration without persisting changes:
138
+
139
+ ```ruby
140
+ migration = Hekenga.find_migration("2024-01-15-add-default-role-to-users")
141
+ migration.test_mode!
142
+ migration.perform!
143
+ ```
144
+
145
+ Or via the CLI:
146
+
147
+ $ hekenga run! <path_or_pkey> --test
148
+
149
+ ### Monitoring
150
+
151
+ Attach to a migration that's already running (for example one launched in another process or via a background job) and print its status on a fixed interval:
152
+
153
+ $ hekenga watch <path_or_pkey>
154
+ $ hekenga watch <path_or_pkey> --interval 5 # report every 5 seconds
155
+
156
+ The reporting interval defaults to `Hekenga.config.report_sleep`.
157
+
158
+ ### Recovery
159
+
160
+ When a migration fails (due to errors, invalid records, or write failures), Hekenga logs the failures and marks the migration as failed. You can re-process only the failed records:
161
+
162
+ $ hekenga recover! <path_or_pkey>
25
163
 
26
- $ hekenga help
164
+ To inspect exactly which documents failed or were invalid across all document tasks in a migration:
27
165
 
28
- Migration DSL documentation TBD, for now please look at spec/
166
+ $ hekenga failures <path_or_pkey>
29
167
 
30
168
  ## Development
31
169
 
data/docker-compose.yml CHANGED
@@ -9,22 +9,41 @@ networks:
9
9
  services:
10
10
  mongo:
11
11
  image: mongo:6
12
- command: ["--replSet", "rs0", "--bind_ip", "localhost,mongo"]
12
+ command: ["--replSet", "rs0", "--bind_ip_all"]
13
13
  volumes:
14
14
  - mongo:/data/db
15
15
  ports:
16
16
  - 27017:27017
17
17
  networks:
18
18
  - hekenga-net
19
+ healthcheck:
20
+ # mongo:6 ships mongosh (the legacy `mongo` shell was removed in 6.0).
21
+ test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
22
+ interval: 5s
23
+ timeout: 5s
24
+ retries: 12
19
25
 
20
26
  mongosetup:
21
27
  image: mongo:6
22
28
  depends_on:
23
- - mongo
29
+ mongo:
30
+ condition: service_healthy
24
31
  restart: "no"
25
32
  entrypoint:
26
- - bash
27
- - "-c"
28
- - "sleep 3 && mongo --host mongo:27017 --eval 'rs.initiate({_id: \"rs0\", members: [{_id: 0, host: \"localhost:27017\"}]})'"
33
+ - mongosh
34
+ - "--quiet"
35
+ - "--host"
36
+ - "mongo:27017"
37
+ - "--eval"
38
+ # Idempotent: initiate the single-member replica set only if it hasn't
39
+ # been configured yet, so re-running compose doesn't error. The member
40
+ # advertises as localhost:27017 so the driver on the host can reach it
41
+ # after replica-set topology discovery (required for transactions).
42
+ - >
43
+ try { rs.status(); print('replica set already initialised'); }
44
+ catch (e) {
45
+ rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27017'}]});
46
+ print('replica set initiated');
47
+ }
29
48
  networks:
30
49
  - hekenga-net
data/exe/hekenga CHANGED
@@ -71,6 +71,20 @@ class HekengaCLI < Thor
71
71
  end
72
72
  end
73
73
 
74
+ desc "watch PATH_OR_PKEY", "Attach to a running migration and report its status periodically."
75
+ option :interval, type: :numeric, desc: "Seconds between status reports (default: Hekenga.config.report_sleep)."
76
+ def watch(path_or_pkey)
77
+ migration = load_migration(path_or_pkey)
78
+ interval = options[:interval] || Hekenga.config.report_sleep
79
+ Hekenga::MasterProcess.new(migration).watch!(interval: interval)
80
+ end
81
+
82
+ desc "failures PATH_OR_PKEY", "Print all failed and invalid document IDs for a migration."
83
+ def failures(path_or_pkey)
84
+ migration = load_migration(path_or_pkey)
85
+ Hekenga::FailureReport.new(migration).print!
86
+ end
87
+
74
88
  desc "cancel", "Cancel all active migrations."
75
89
  def cancel
76
90
  Hekenga::Log.where(done: false).set(cancel: true)
@@ -5,7 +5,7 @@ module Hekenga
5
5
  attr_reader :ups, :downs, :setups, :filters, :after_callbacks
6
6
  attr_accessor :parallel, :scope, :timeless, :batch_size, :cursor_timeout
7
7
  attr_accessor :description, :invalid_strategy, :skip_prepare, :write_strategy
8
- attr_accessor :always_write, :use_transaction
8
+ attr_accessor :always_write, :use_transaction, :skip_validation
9
9
 
10
10
  def initialize
11
11
  @ups = []
@@ -16,6 +16,7 @@ module Hekenga
16
16
  @invalid_strategy = :continue
17
17
  @write_strategy = :update
18
18
  @skip_prepare = false
19
+ @skip_validation = false
19
20
  @batch_size = nil
20
21
  @always_write = false
21
22
  @use_transaction = false
@@ -24,6 +25,9 @@ module Hekenga
24
25
 
25
26
  def validate!
26
27
  raise Hekenga::Invalid.new(self, :ups, "missing") unless ups.any?
28
+ if scope&.options&.key?(:fields)
29
+ raise Hekenga::Invalid.new(self, :scope, "uses .only() or .without() which would cause data loss with replace_one")
30
+ end
27
31
  end
28
32
 
29
33
  def up!(context, document)
@@ -59,7 +59,11 @@ module Hekenga
59
59
  end
60
60
 
61
61
  def record_scope
62
- task.scope.klass.unscoped.in(_id: task_record.ids)
62
+ scope = task.scope.klass.unscoped.in(_id: task_record.ids)
63
+ if task.scope.inclusions.any?
64
+ scope = scope.includes(*task.scope.inclusions.map(&:name))
65
+ end
66
+ scope
63
67
  end
64
68
 
65
69
  def records
@@ -112,6 +116,8 @@ module Hekenga
112
116
  end
113
117
 
114
118
  def validate_records
119
+ return records_to_write.concat(migrated_records) if task.skip_validation
120
+
115
121
  migrated_records.each do |record|
116
122
  if record.valid?
117
123
  records_to_write << record
@@ -56,6 +56,10 @@ module Hekenga
56
56
  @object.skip_prepare = true
57
57
  end
58
58
 
59
+ def skip_validation!
60
+ @object.skip_validation = true
61
+ end
62
+
59
63
  def setup(&block)
60
64
  @object.setups.push block
61
65
  end
@@ -0,0 +1,60 @@
1
+ module Hekenga
2
+ class FailureReport
3
+ def initialize(migration)
4
+ @migration = migration
5
+ end
6
+
7
+ # Per-task summaries for document tasks that have any failed or invalid
8
+ # document IDs.
9
+ # => [{ idx:, description:, failed_ids: [...], invalid_ids: [...] }, ...]
10
+ def tasks
11
+ @migration.tasks.each.with_index.filter_map do |task, idx|
12
+ next unless task.is_a?(Hekenga::DocumentTask)
13
+
14
+ records = @migration.task_records(idx).any_of(
15
+ { :failed_ids.ne => [] },
16
+ { :invalid_ids.ne => [] }
17
+ )
18
+
19
+ failed_ids = []
20
+ invalid_ids = []
21
+ records.pluck(:failed_ids, :invalid_ids).each do |failed, invalid|
22
+ failed_ids.concat(failed) if failed
23
+ invalid_ids.concat(invalid) if invalid
24
+ end
25
+ next if failed_ids.empty? && invalid_ids.empty?
26
+
27
+ {
28
+ idx: idx,
29
+ description: task.description,
30
+ failed_ids: failed_ids,
31
+ invalid_ids: invalid_ids
32
+ }
33
+ end
34
+ end
35
+
36
+ def print!
37
+ Hekenga.log("Failures for #{@migration.to_key}")
38
+
39
+ summaries = tasks
40
+ if summaries.empty?
41
+ Hekenga.log(" No failed or invalid documents.")
42
+ return
43
+ end
44
+
45
+ failed_ids = summaries.flat_map { |summary| summary[:failed_ids] }
46
+ invalid_ids = summaries.flat_map { |summary| summary[:invalid_ids] }
47
+
48
+ print_ids("Failed", failed_ids)
49
+ print_ids("Invalid", invalid_ids)
50
+ end
51
+
52
+ private
53
+
54
+ def print_ids(label, ids)
55
+ return if ids.empty?
56
+
57
+ Hekenga.log(" #{label} (#{ids.length}): #{ids.map { |id| "\"#{id}\"" }.join(", ")}")
58
+ end
59
+ end
60
+ end
@@ -33,18 +33,35 @@ module Hekenga
33
33
  true
34
34
  end
35
35
 
36
+ def watch!(interval: Hekenga.config.report_sleep)
37
+ if Hekenga.status(@migration) == :naught
38
+ Hekenga.log "Migration #{@migration.to_key} has not started yet."
39
+ return false
40
+ end
41
+ Hekenga.log "Watching migration #{@migration.to_key}: #{@migration.description}"
42
+ @migration.tasks.each.with_index do |task, idx|
43
+ report_while_active(task, idx, interval: interval)
44
+ rescue Hekenga::TaskFailedError
45
+ return false
46
+ end
47
+ true
48
+ end
49
+
36
50
  private
37
51
 
38
- def report_while_active(task, idx)
39
- # Wait for the log to be generated
52
+ def report_while_active(task, idx, interval: Hekenga.config.report_sleep)
53
+ # Wait for the log to be generated. When watching an out-of-process
54
+ # migration there is no @active_thread guaranteeing it, so bail out if
55
+ # the migration finished without ever reaching this task.
40
56
  until (@migration.log(idx) rescue nil)
57
+ return if %i[complete skipped].include?(Hekenga.status(@migration))
41
58
  sleep 1
42
59
  end
43
- # Periodically report on thread progress
60
+ # Periodically report on progress
44
61
  until @migration.log(idx).reload.done
45
- @active_thread.join
62
+ @active_thread&.join
46
63
  report_status(task, idx)
47
- sleep Hekenga.config.report_sleep
64
+ sleep interval
48
65
  end
49
66
  report_status(task, idx) if task.is_a?(Hekenga::DocumentTask)
50
67
  report_result(task, idx)
@@ -1,3 +1,3 @@
1
1
  module Hekenga
2
- VERSION = "2.0.0"
2
+ VERSION = "2.2.0"
3
3
  end
data/lib/hekenga.rb CHANGED
@@ -6,6 +6,7 @@ require "hekenga/config"
6
6
  require "hekenga/irreversible"
7
7
  require "hekenga/virtual_method"
8
8
  require "hekenga/scaffold"
9
+ require "hekenga/failure_report"
9
10
 
10
11
  module Hekenga
11
12
  @@load_all_mutex = Mutex.new
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hekenga
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tapio Saarinen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-31 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -148,6 +148,7 @@ files:
148
148
  - ".rspec"
149
149
  - ".travis.yml"
150
150
  - CHANGELOG.md
151
+ - CLAUDE.md
151
152
  - Gemfile
152
153
  - README.md
153
154
  - Rakefile
@@ -174,6 +175,7 @@ files:
174
175
  - lib/hekenga/failure/error.rb
175
176
  - lib/hekenga/failure/validation.rb
176
177
  - lib/hekenga/failure/write.rb
178
+ - lib/hekenga/failure_report.rb
177
179
  - lib/hekenga/id_iterator.rb
178
180
  - lib/hekenga/invalid.rb
179
181
  - lib/hekenga/irreversible.rb