hekenga 0.2.13 → 1.0.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: 899109fdb4f7876b3d4baba9355bb6635dbf371754eabc228536fbaa3428799a
4
- data.tar.gz: cfedba047f13540a2be8d933002908c0e5d40b3580dc4375ab0ecb121d42d14f
3
+ metadata.gz: 4befa0d3716a0609575aa42f35fe126b854365f5e348ecb6b8babff4912e2522
4
+ data.tar.gz: 674e20c142d8b8ebc0f51e75a4a372912359c196247471170c5ddae2c317c294
5
5
  SHA512:
6
- metadata.gz: 615217a1c3b455cea8cea7e13322f7fa023d1da01c609a10a71188e60b6b847671a744a560f9e7ec3d18b969105e04f504972d9a313e1b8d168baabb691edf88
7
- data.tar.gz: 14f4ffe561e2758d5158027fbc28f9242cf0d30f186fb84c05b6a6843310d4554932d0b83d41c7208aee1c071359ad8329c66bbbd26c386a085d8aa03cc24ec0
6
+ metadata.gz: 6625430dc0dc24bf821e7c584fa1141362de50effdf5606ed53583b77d20b8e79b7bd17680128b25e736c24a8fb41b58169f9a0b106bd693165abfec339c49b2
7
+ data.tar.gz: 84a0af87ac9b21acc5253206d4de311adc6b5edbe47303894aeb1eb169ac89365f6338f79f67257478cc744ad98d8a23fe8002ee67c64b276f0a5e33be214685
data/.gitignore CHANGED
@@ -7,4 +7,5 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
- .versions.conf
10
+ *.gem
11
+ todo.txt
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## v1.0.0
4
+
5
+ - `hekenga run!` now has a `--clear` option to clear the migration prior to running it
6
+ - (breaking) `per_document` tasks now generate `Hekenga::DocumentTaskRecord`, used for
7
+ recovery + progress tracking. `Hekenga::Failure::Cancelled` and
8
+ `Hekenga::Failure::Validation` are no longer generated.
9
+ - (breaking) Migrations will now always continue when encountering invalid records.
10
+ Recovering the migration later will reprocess the invalid records. Support
11
+ for the prompt/cancel/stop when_invalid strategies has been dropped
12
+ - `rollback`, `errors` CLI stubs have been removed as they were never
13
+ implemented
14
+ - `--edit` now no longer eats an argument when generating a migration
15
+ - Mutexes have been added to the registry to help with thread-safety. You will
16
+ still need to make sure that your application is eager loaded on workers
17
+ - (breaking) The default write strategy has been changed to use replace bulk operations
18
+ instead of a batch delete followed by a batch insert. You can swap back to
19
+ delete then insert on a per-task basis, but you probably shouldn't
20
+ - An experimental option to wrap each document batch in a transaction has been
21
+ added.
22
+ - (breaking) If a migration doesn't change a document, it will now skip writing
23
+ it by default. To always write the document, call `always_write!`
24
+ - `batch_size` can now be configured per document task
25
+ - Rather than doing a single `pluck` on the `per_document` scope, the scope will
26
+ now be iterated in subqueries of 100k IDs when retrieving IDs to queue jobs
27
+ for.
28
+ - (breaking) `Hekenga::ParallelJob` takes different arguments now.
29
+ - (breaking) some internal methods have been renamed.
@@ -0,0 +1,30 @@
1
+ version: "3.8"
2
+
3
+ volumes:
4
+ mongo:
5
+
6
+ networks:
7
+ hekenga-net:
8
+
9
+ services:
10
+ mongo:
11
+ image: mongo:5
12
+ command: ["--replSet", "rs0", "--bind_ip", "localhost,mongo"]
13
+ volumes:
14
+ - mongo:/data/db
15
+ ports:
16
+ - 27017:27017
17
+ networks:
18
+ - hekenga-net
19
+
20
+ mongosetup:
21
+ image: mongo:5
22
+ depends_on:
23
+ - mongo
24
+ restart: "no"
25
+ entrypoint:
26
+ - bash
27
+ - "-c"
28
+ - "sleep 3 && mongo --host mongo:27017 --eval 'rs.initiate({_id: \"rs0\", members: [{_id: 0, host: \"localhost:27017\"}]})'"
29
+ networks:
30
+ - hekenga-net
data/exe/hekenga CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "bundler/setup"
4
4
 
5
- if File.exists?(File.expand_path("config/environment.rb"))
5
+ if File.exist?(File.expand_path("config/environment.rb"))
6
6
  require File.expand_path("config/environment.rb")
7
7
  end
8
8
 
@@ -11,6 +11,10 @@ require "thor"
11
11
  require "pathname"
12
12
 
13
13
  class HekengaCLI < Thor
14
+ def self.exit_on_failure?
15
+ true
16
+ end
17
+
14
18
  desc "status", "Show which migrations have run and their status."
15
19
  def status
16
20
  Hekenga.load_all!
@@ -33,31 +37,37 @@ class HekengaCLI < Thor
33
37
 
34
38
  desc "run_all!", "Run all migrations that have not yet run, in date order."
35
39
  def run_all!
36
- bail_if_errors
37
40
  Hekenga.load_all!
38
41
  Hekenga.registry.sort_by {|x| x.stamp}.each do |migration|
39
42
  if Hekenga.status(migration) == :naught
40
43
  migration.perform!
41
- bail_if_errors
42
44
  end
43
45
  end
44
46
  end
45
47
 
46
48
  desc "run! PATH_OR_PKEY --test", "Run a migration (optionally in test mode)."
47
49
  option :test, default: false, type: :boolean
50
+ option :clear, default: false, type: :boolean
48
51
  def run!(path_or_pkey)
49
- bail_if_errors
52
+ clear!(path_or_pkey) if options[:clear]
50
53
  migration = load_migration(path_or_pkey)
51
54
  migration.test_mode! if options[:test]
52
55
  migration.perform!
53
56
  if options[:test]
54
- if Hekenga::Failure.where(pkey: migration.to_key).any?
55
- puts "Logs have been preserved for debugging. To reset migration state run:"
56
- puts " hekenga clear! #{path_or_pkey}"
57
- else
58
- puts "Migration test run completed successfully."
59
- clear!(path_or_pkey)
60
- end
57
+ puts "Logs have been preserved for debugging. To reset migration state run:"
58
+ puts " hekenga clear! #{path_or_pkey}"
59
+ end
60
+ end
61
+
62
+ desc "recover! PATH_OR_PKEY", "Recover a migration"
63
+ option :test, default: false, type: :boolean
64
+ def recover!(path_or_pkey)
65
+ migration = load_migration(path_or_pkey)
66
+ migration.test_mode! if options[:test]
67
+ migration.recover!
68
+ if options[:test]
69
+ puts "Logs have been preserved for debugging. To reset migration state run:"
70
+ puts " hekenga clear! #{path_or_pkey}"
61
71
  end
62
72
  end
63
73
 
@@ -79,24 +89,10 @@ class HekengaCLI < Thor
79
89
  puts "Clearing #{migration.to_key}.."
80
90
  Hekenga::Log.where(pkey: migration.to_key).delete_all
81
91
  Hekenga::Failure.where(pkey: migration.to_key).delete_all
92
+ Hekenga::DocumentTaskRecord.where(migration_key: migration.to_key).delete_all
82
93
  puts "Done!"
83
94
  end
84
95
 
85
- desc "rollback", "Rollback a migration."
86
- def rollback
87
- todo "rollback"
88
- end
89
-
90
- desc "recover", "Attempt to resume a failed migration."
91
- def recover
92
- todo "recover"
93
- end
94
-
95
- desc "errors", "Print the errors associated with a failed migration."
96
- def errors
97
- todo "errors"
98
- end
99
-
100
96
  desc "skip PATH_OR_PKEY", "Skip a migration so that it won't run."
101
97
  def skip(path_or_pkey)
102
98
  migration = load_migration(path_or_pkey)
@@ -114,7 +110,7 @@ class HekengaCLI < Thor
114
110
  end
115
111
 
116
112
  desc "generate <description> --edit", "Generate a migration scaffold (and optionally edit in your editor)."
117
- option :edit
113
+ option :edit, type: :boolean
118
114
  def generate(*description)
119
115
  description = description.join(" ")
120
116
  scaffold = Hekenga::Scaffold.new(description)
@@ -130,20 +126,11 @@ class HekengaCLI < Thor
130
126
  exec "$EDITOR #{path}"
131
127
  end
132
128
  end
129
+
133
130
  private
134
131
 
135
- def todo(op)
136
- puts "#{op.capitalize} has not yet been implemented."
137
- exit(99)
138
- end
139
- def bail_if_errors
140
- if Hekenga.any_fatal?
141
- puts "Refusing to run migrations while there is an existing cancelled migration."
142
- exit(1)
143
- end
144
- end
145
132
  def load_migration(path_or_pkey)
146
- if File.exists?(File.expand_path(path_or_pkey))
133
+ if File.exist?(File.expand_path(path_or_pkey))
147
134
  require File.expand_path(path_or_pkey)
148
135
  migration = Hekenga.registry.last
149
136
  else
data/hekenga.gemspec CHANGED
@@ -8,6 +8,7 @@ Gem::Specification.new do |spec|
8
8
  spec.version = Hekenga::VERSION
9
9
  spec.authors = ["Tapio Saarinen"]
10
10
  spec.email = ["admin@bitlong.org"]
11
+ spec.licenses = ["MIT"]
11
12
 
12
13
  spec.summary = %q{Sophisticated migration framework for mongoid, with the ability to parallelise via ActiveJob.}
13
14
  spec.homepage = "https://github.com/tzar/hekenga"
@@ -19,14 +20,14 @@ Gem::Specification.new do |spec|
19
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
21
  spec.require_paths = ["lib"]
21
22
 
22
- spec.add_development_dependency "bundler", "~> 2.2.10"
23
+ spec.add_development_dependency "bundler", "~> 2.4.6"
23
24
  spec.add_development_dependency "rake", ">= 12.3.3"
24
25
  spec.add_development_dependency "rspec", "~> 3.0"
25
- spec.add_development_dependency "database_cleaner"
26
+ spec.add_development_dependency "database_cleaner-mongoid", "~> 2.0"
26
27
  spec.add_development_dependency "pry"
27
28
  spec.add_development_dependency "pry-byebug"
28
29
 
29
- spec.add_runtime_dependency "mongoid", ">= 5"
30
- spec.add_runtime_dependency "activejob", ">= 4"
30
+ spec.add_runtime_dependency "mongoid", ">= 6"
31
+ spec.add_runtime_dependency "activejob", ">= 5"
31
32
  spec.add_runtime_dependency "thor"
32
33
  end
@@ -1,14 +1,15 @@
1
1
  module Hekenga
2
2
  class Context
3
- def initialize(test_run)
4
- @__test_run = test_run
3
+ def initialize(test_mode: false)
4
+ @__test_mode = test_mode
5
5
  end
6
6
 
7
- def test?
8
- !!@__test_run
9
- end
10
7
  def actual?
11
- !@__test_run
8
+ !@__test_mode
9
+ end
10
+
11
+ def test?
12
+ !actual?
12
13
  end
13
14
  end
14
15
  end
@@ -2,15 +2,21 @@ require 'hekenga/irreversible'
2
2
  module Hekenga
3
3
  class DocumentTask
4
4
  attr_reader :ups, :downs, :setups, :filters
5
- attr_accessor :parallel, :scope, :timeless
6
- attr_accessor :description, :invalid_strategy, :skip_prepare
5
+ attr_accessor :parallel, :scope, :timeless, :batch_size
6
+ attr_accessor :description, :invalid_strategy, :skip_prepare, :write_strategy
7
+ attr_accessor :always_write, :use_transaction
8
+
7
9
  def initialize
8
10
  @ups = []
9
11
  @downs = []
10
12
  @setups = []
11
13
  @filters = []
12
- @invalid_strategy = :prompt
14
+ @invalid_strategy = :continue
15
+ @write_strategy = :update
13
16
  @skip_prepare = false
17
+ @batch_size = nil
18
+ @always_write = false
19
+ @use_transaction = false
14
20
  end
15
21
 
16
22
  def validate!
@@ -0,0 +1,264 @@
1
+ module Hekenga
2
+ class DocumentTaskExecutor
3
+ attr_reader :task_record
4
+ attr_reader :context, :session
5
+
6
+ def initialize(task_record, records: nil)
7
+ @task_record = task_record
8
+ @records = records
9
+ @migrated_records = []
10
+ @invalid_records = []
11
+ @records_to_write = []
12
+ @filtered_records = []
13
+ @skipped_records = []
14
+ @failed_records = []
15
+ @backed_up_records = {}
16
+ end
17
+
18
+ def run!
19
+ with_setup do |context|
20
+ with_transaction do |session|
21
+ filter_records
22
+ run_migration
23
+ validate_records
24
+ write_records
25
+ write_result unless task_record.test_mode
26
+ end
27
+ # In test mode, the transaction will be aborted - so we need to write
28
+ # the result outside of the run! block
29
+ write_result if task_record.test_mode
30
+ end
31
+ end
32
+
33
+ def check_for_completion!
34
+ if migration_complete?
35
+ migration.log(task_idx).set_without_session(
36
+ done: true,
37
+ finished: Time.now,
38
+ error: migration.task_records(task_idx).failed.any?
39
+ )
40
+ end
41
+ end
42
+
43
+ def migration_cancelled?
44
+ migration.log(task_idx).cancel
45
+ end
46
+
47
+ private
48
+
49
+ delegate :task_idx, to: :task_record
50
+
51
+ attr_reader :migrated_records, :records_to_write, :filtered_records, :invalid_records, :skipped_records, :failed_records, :backed_up_records
52
+
53
+ def migration_complete?
54
+ migration.task_records(task_idx).incomplete.none?
55
+ end
56
+
57
+ def record_scope
58
+ task.scope.klass.unscoped.in(_id: task_record.ids)
59
+ end
60
+
61
+ def records
62
+ @records ||= record_scope.to_a
63
+ end
64
+
65
+ def write_result
66
+ task_record.update_attributes(
67
+ complete: true,
68
+ finished: Time.now,
69
+ failed_ids: failed_records.map(&:_id),
70
+ invalid_ids: invalid_records.map(&:_id),
71
+ written_ids: records_to_write.map(&:_id),
72
+ stats: {
73
+ failed: failed_records.length,
74
+ invalid: invalid_records.length,
75
+ written: records_to_write.length
76
+ }
77
+ )
78
+ end
79
+
80
+ def filter_records
81
+ records.each do |record|
82
+ if task.filters.all? {|block| context.instance_exec(record, &block)}
83
+ filtered_records << record
84
+ else
85
+ skipped_records << record
86
+ end
87
+ rescue => _e
88
+ failed_records << record
89
+ end
90
+ end
91
+
92
+ def run_migration
93
+ filtered_records.each do |record|
94
+ backup_record(record)
95
+ task.up!(@context, record)
96
+ migrated_records << record
97
+ rescue => _e
98
+ failed_records << record
99
+ end
100
+ end
101
+
102
+ def backup_record(record)
103
+ return unless task.write_strategy == :delete_then_insert
104
+
105
+ backed_up_records[record._id] = record.as_document.deep_dup
106
+ end
107
+
108
+ def validate_records
109
+ migrated_records.each do |record|
110
+ if record.valid?
111
+ records_to_write << record
112
+ else
113
+ invalid_records << record
114
+ end
115
+ end
116
+ end
117
+
118
+ def write_records
119
+ records_to_write.keep_if(&:changed?) unless task.always_write
120
+ return if records_to_write.empty?
121
+ return if task_record.test_mode
122
+
123
+ records_to_write.each {|record| record.send(:prepare_update) {}}
124
+
125
+ case task.write_strategy
126
+ when :delete_then_insert
127
+ delete_then_insert_records
128
+ else
129
+ replace_records
130
+ end
131
+ rescue Mongo::Error::BulkWriteError => e
132
+ # If we're in a transaction, we can retry; so just re-raise
133
+ raise if @session
134
+ # Otherwise, we need to log the failure
135
+ write_failure!(e)
136
+ end
137
+
138
+ def write_failure!(error)
139
+ log = migration.log(task_idx)
140
+ backups = records_to_write.map do |record|
141
+ failed_records << record
142
+ backed_up_records[record._id]
143
+ end.compact
144
+ @records_to_write = []
145
+ log.add_failure({
146
+ message: error.to_s,
147
+ backtrace: error.backtrace,
148
+ documents: backups,
149
+ document_ids: records_to_write.map(&:_id),
150
+ task_record_id: task_record.id
151
+ }, Hekenga::Failure::Write)
152
+ log.set_without_session({error: true})
153
+ end
154
+
155
+ def delete_then_insert_records
156
+ operations = []
157
+ operations << {
158
+ delete_many: {
159
+ filter: {
160
+ _id: {
161
+ '$in': records_to_write.map(&:_id)
162
+ }
163
+ }
164
+ }
165
+ }
166
+ records_to_write.each do |record|
167
+ operations << { insert_one: record.as_document }
168
+ end
169
+ bulk_write(operations, ordered: true)
170
+ end
171
+
172
+ def bulk_write(operations, **options)
173
+ task.scope.klass.collection.bulk_write(operations, session: session, **options)
174
+ end
175
+
176
+ def replace_records
177
+ operations = records_to_write.map do |record|
178
+ {
179
+ replace_one: {
180
+ filter: { _id: record._id },
181
+ replacement: record.as_document
182
+ }
183
+ }
184
+ end
185
+ bulk_write(operations)
186
+ end
187
+
188
+ def migration
189
+ @migration ||= Hekenga.find_migration(task_record.migration_key)
190
+ end
191
+
192
+ def with_setup(&block)
193
+ @context = Hekenga::Context.new(test_mode: task_record.test_mode)
194
+ begin
195
+ task.setups&.each do |setup|
196
+ @context.instance_exec(&setup)
197
+ end
198
+ rescue => e
199
+ fail_and_cancel!(e)
200
+ return
201
+ end
202
+ yield
203
+ ensure
204
+ @context = nil
205
+ end
206
+
207
+ def fail_and_cancel!(error)
208
+ log = migration.log(task_idx)
209
+ log.add_failure({
210
+ message: error.to_s,
211
+ backtrace: error.backtrace
212
+ }, Hekenga::Failure::Error)
213
+ log.set_without_session({cancel: true, error: true, done: true, finished: Time.now})
214
+ task_record.update_attributes(
215
+ complete: true,
216
+ finished: Time.now,
217
+ failed_ids: task_record.ids,
218
+ invalid_ids: [],
219
+ written_ids: [],
220
+ stats: {
221
+ failed: task_record.ids.length,
222
+ invalid: 0,
223
+ written: 0,
224
+ }
225
+ )
226
+ end
227
+
228
+ def with_transaction(&block)
229
+ return yield unless task.use_transaction
230
+
231
+ ensure_replicaset!
232
+ klass = task.scope.klass
233
+ # NOTE: Dummy session to work around threading bug
234
+ klass.persistence_context.client.start_session({})
235
+ klass.with_session do |session|
236
+ @session = session
237
+ @session.start_transaction
238
+ yield
239
+ if task_record.test_mode
240
+ @session.abort_transaction
241
+ else
242
+ @session.commit_transaction
243
+ end
244
+ rescue
245
+ @session.abort_transaction
246
+ raise
247
+ ensure
248
+ @session = nil
249
+ end
250
+ end
251
+
252
+ def ensure_replicaset!
253
+ return unless task.use_transaction
254
+
255
+ unless task.scope.klass.collection.client.cluster.replica_set?
256
+ raise "MongoDB must be in a replica set to use transactions"
257
+ end
258
+ end
259
+
260
+ def task
261
+ @task ||= migration.tasks[task_idx]
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,29 @@
1
+ module Hekenga
2
+ class DocumentTaskRecord
3
+ include Mongoid::Document
4
+
5
+ field :migration_key, type: String
6
+ field :task_idx, type: Integer
7
+ field :executor_key, type: BSON::ObjectId
8
+ field :finished, type: Time
9
+ field :complete, type: Boolean, default: false
10
+ field :ids, type: Array, default: []
11
+ field :id_count, type: Integer
12
+ field :test_mode, type: Boolean
13
+
14
+ field :stats, type: Hash, default: {}
15
+ field :failed_ids, type: Array, default: []
16
+ field :invalid_ids, type: Array, default: []
17
+ field :written_ids, type: Array, default: []
18
+
19
+ index(migration_key: 1, task_idx: 1, complete: 1)
20
+ index(migration_key: 1, task_idx: 1, 'stats.failed': 1)
21
+ index(migration_key: 1, task_idx: 1, ids: 1)
22
+
23
+ scope :incomplete, proc { where(complete: false) }
24
+ scope :complete, proc { where(complete: true) }
25
+ scope :failed, proc { gt('stats.failed': 1) }
26
+
27
+ before_create { self.id_count = ids.count }
28
+ end
29
+ end
@@ -4,7 +4,15 @@ module Hekenga
4
4
  class DocumentTask < Hekenga::DSL
5
5
  configures Hekenga::DocumentTask
6
6
 
7
- INVALID_BEHAVIOR_STRATEGIES = [:prompt, :cancel, :stop, :continue]
7
+ INVALID_BEHAVIOR_STRATEGIES = %i[continue]
8
+ VALID_WRITE_STRATEGIES = %i[delete_then_insert update]
9
+
10
+ def batch_size(size)
11
+ unless size.is_a?(Integer) && size > 0
12
+ raise "Invalid batch size #{size.inspect}"
13
+ end
14
+ @object.batch_size = size
15
+ end
8
16
 
9
17
  def when_invalid(val)
10
18
  unless INVALID_BEHAVIOR_STRATEGIES.include?(val)
@@ -13,18 +21,37 @@ module Hekenga
13
21
  @object.invalid_strategy = val
14
22
  end
15
23
 
24
+ def write_strategy(strategy)
25
+ unless VALID_WRITE_STRATEGIES.include?(strategy)
26
+ raise "Invalid value #{strategy}. Valid values for write_strategy are: #{VALID_WRITE_STRATEGIES.join(", ")}."
27
+ end
28
+ @object.write_strategy = strategy
29
+ end
30
+
16
31
  def scope(scope)
17
32
  @object.scope = scope
18
33
  end
34
+
35
+ def always_write!
36
+ @object.always_write = true
37
+ end
38
+
39
+ def use_transaction!
40
+ @object.use_transaction = true
41
+ end
42
+
19
43
  def parallel!
20
44
  @object.parallel = true
21
45
  end
46
+
22
47
  def timeless!
23
48
  @object.timeless = true
24
49
  end
50
+
25
51
  def skip_prepare!
26
52
  @object.skip_prepare = true
27
53
  end
54
+
28
55
  def setup(&block)
29
56
  @object.setups.push block
30
57
  end
@@ -11,12 +11,15 @@ module Hekenga
11
11
  end
12
12
  @object.batch_size = size
13
13
  end
14
+
14
15
  def created(stamp = nil)
15
16
  @object.stamp = Time.parse(stamp)
16
17
  end
18
+
17
19
  def task(description = nil, &block)
18
20
  @object.tasks.push Hekenga::DSL::SimpleTask.new(description, &block).object
19
21
  end
22
+
20
23
  def per_document(description = nil, &block)
21
24
  @object.tasks.push Hekenga::DSL::DocumentTask.new(description, &block).object
22
25
  end
@@ -6,6 +6,7 @@ module Hekenga
6
6
  field :documents
7
7
  field :document_ids, type: Array
8
8
  field :batch_start
9
+ field :task_record_id
9
10
  end
10
11
  end
11
12
  end
@@ -0,0 +1,26 @@
1
+ module Hekenga
2
+ class Iterator
3
+ include Enumerable
4
+
5
+ SMALLEST_ID = BSON::ObjectId.from_string('0'*24)
6
+
7
+ attr_reader :scope, :size
8
+
9
+ def initialize(scope, size:)
10
+ @scope = scope
11
+ @size = size
12
+ end
13
+
14
+ def each(&block)
15
+ current_id = SMALLEST_ID
16
+ base_scope = scope.asc(:_id).limit(size)
17
+
18
+ loop do
19
+ ids = base_scope.and(_id: {'$gt': current_id}).pluck(:_id)
20
+ break if ids.empty?
21
+ yield ids
22
+ current_id = ids.sort.last
23
+ end
24
+ end
25
+ end
26
+ end