hekenga 0.2.13 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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