bookie_accounting 1.0.0 → 1.1.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.
@@ -1,11 +1,11 @@
1
1
  require 'bookie/config'
2
+ require 'bookie/extensions'
2
3
 
3
4
  require 'active_record'
4
5
 
5
6
  module Bookie
6
- #Contains ActiveRecord structures for the central database
7
- #
8
- #For a list of fields in the various models, see {Database Tables}[link:rdoc/database_rdoc.html]
7
+ ##
8
+ #Contains database-related code and models
9
9
  module Database
10
10
 
11
11
  ##
@@ -57,6 +57,8 @@ module Bookie
57
57
  def end_time
58
58
  return start_time + wall_time
59
59
  end
60
+
61
+ #To consider: disable #end_time= ?
60
62
 
61
63
  def self.by_user(user)
62
64
  where('jobs.user_id = ?', user.id)
@@ -83,7 +85,7 @@ module Bookie
83
85
  def self.by_group_name(group_name)
84
86
  group = Group.find_by_name(group_name)
85
87
  return joins(:user).where('users.group_id = ?', group.id) if group
86
- limit(0)
88
+ where('1=0')
87
89
  end
88
90
 
89
91
  ##
@@ -113,7 +115,13 @@ module Bookie
113
115
  ##
114
116
  #Finds all jobs whose running intervals overlap the given time range
115
117
  def self.by_time_range_inclusive(time_range)
116
- where('? <= jobs.end_time AND jobs.start_time < ?', time_range.first, time_range.last)
118
+ if time_range.empty?
119
+ where('1=0')
120
+ elsif time_range.exclude_end?
121
+ where('? <= jobs.end_time AND jobs.start_time < ?', time_range.first, time_range.last)
122
+ else
123
+ where('? <= jobs.end_time AND jobs.start_time <= ?', time_range.first, time_range.last)
124
+ end
117
125
  end
118
126
 
119
127
  ##
@@ -125,12 +133,14 @@ module Bookie
125
133
  #- <tt>:memory_time</tt>: the sum of memory * wall_time for all jobs in the interval
126
134
  #- <tt>:successful</tt>: the number of jobs that have completed successfully
127
135
  #
128
- #This method should probably not be used with other queries that filter by start/end time.
136
+ #This method should probably not be chained with other queries that filter by start/end time.
137
+ #
138
+ #To consider: filter out jobs with 0 CPU time?
129
139
  def self.summary(time_range = nil)
130
140
  time_range = time_range.normalized if time_range
131
141
  jobs = self
132
142
  jobs = jobs.by_time_range_inclusive(time_range) if time_range
133
- jobs = jobs.where('jobs.cpu_time > 0').all_with_relations
143
+ jobs = jobs.all_with_relations
134
144
  cpu_time = 0
135
145
  successful_jobs = 0
136
146
  memory_time = 0
@@ -151,10 +161,7 @@ module Bookie
151
161
  #To consider: what should I do about jobs that only report a max memory value?
152
162
  memory_time += job.memory * clipped_wall_time
153
163
  end
154
- #Only count the job as successful if it's actually finished by the end of the summary.
155
- if job.exit_code == 0 && (!time_range || job.end_time < time_range.end) then
156
- successful_jobs += 1
157
- end
164
+ successful_jobs += 1 if job.exit_code == 0
158
165
  end
159
166
 
160
167
  return {
@@ -171,40 +178,38 @@ module Bookie
171
178
  #Relations are not cached between calls.
172
179
  def self.all_with_relations
173
180
  jobs = all
174
- transaction do
175
- users = {}
176
- groups = {}
177
- systems = {}
178
- system_types = {}
179
- jobs.each do |job|
180
- system = systems[job.system_id]
181
- if system
182
- job.system = system
183
- else
184
- system = job.system
185
- systems[system.id] = system
186
- end
187
- system_type = system_types[system.system_type_id]
188
- if system_type
189
- system.system_type = system_type
190
- else
191
- system_type = system.system_type
192
- system_types[system_type.id] = system_type
193
- end
194
- user = users[job.user_id]
195
- if user
196
- job.user = user
197
- else
198
- user = job.user
199
- users[user.id] = user
200
- end
201
- group = groups[user.group_id]
202
- if group
203
- user.group = group
204
- else
205
- group = user.group
206
- groups[group.id] = group
207
- end
181
+ users = {}
182
+ groups = {}
183
+ systems = {}
184
+ system_types = {}
185
+ jobs.each do |job|
186
+ system = systems[job.system_id]
187
+ if system
188
+ job.system = system
189
+ else
190
+ system = job.system
191
+ systems[system.id] = system
192
+ end
193
+ system_type = system_types[system.system_type_id]
194
+ if system_type
195
+ system.system_type = system_type
196
+ else
197
+ system_type = system.system_type
198
+ system_types[system_type.id] = system_type
199
+ end
200
+ user = users[job.user_id]
201
+ if user
202
+ job.user = user
203
+ else
204
+ user = job.user
205
+ users[user.id] = user
206
+ end
207
+ group = groups[user.group_id]
208
+ if group
209
+ user.group = group
210
+ else
211
+ group = user.group
212
+ groups[group.id] = group
208
213
  end
209
214
  end
210
215
 
@@ -231,50 +236,89 @@ module Bookie
231
236
  end
232
237
  end
233
238
 
239
+ ##
240
+ #A cached summary of Jobs in the database
241
+ #
242
+ #Most summary operations should be performed through this class to improve efficiency.
234
243
  class JobSummary < ActiveRecord::Base
235
244
  self.table_name = :job_summaries
236
245
 
237
246
  belongs_to :user
238
247
  belongs_to :system
248
+
249
+ attr_accessible :date, :user, :user_id, :system, :system_id, :command_name, :cpu_time, :memory_time
239
250
 
251
+ ##
252
+ #Filters by date
240
253
  def self.by_date(date)
241
254
  where('job_summaries.date = ?', date)
242
255
  end
256
+
257
+ ##
258
+ #Filters by a date range
259
+ def self.by_date_range(range)
260
+ range = range.normalized
261
+ if range.exclude_end?
262
+ where('? <= job_summaries.date AND job_summaries.date < ?', range.begin, range.end)
263
+ else
264
+ where('? <= job_summaries.date AND job_summaries.date <= ?', range.begin, range.end)
265
+ end
266
+ end
243
267
 
268
+ ##
269
+ #Filters by user
244
270
  def self.by_user(user)
245
271
  where('job_summaries.user_id = ?', user.id)
246
272
  end
247
273
 
274
+ ##
275
+ #Filters by user name
248
276
  def self.by_user_name(name)
249
277
  joins(:user).where('users.name = ?', name)
250
278
  end
251
279
 
280
+ ##
281
+ #Filters by group
252
282
  def self.by_group(group)
253
283
  joins(:user).where('users.group_id = ?', group.id)
254
284
  end
255
285
 
286
+ ##
287
+ #Filters by group name
256
288
  def self.by_group_name(name)
257
289
  group = Group.find_by_name(name)
258
290
  return by_group(group) if group
259
- limit(0)
291
+ where('1=0')
260
292
  end
261
293
 
294
+ ##
295
+ #Filters by system
262
296
  def self.by_system(system)
263
297
  where('job_summaries.system_id = ?', system.id)
264
298
  end
265
299
 
300
+ ##
301
+ #Filters by system name
266
302
  def self.by_system_name(name)
267
303
  joins(:system).where('systems.name = ?', name)
268
304
  end
269
305
 
306
+ ##
307
+ #Filters by system type
270
308
  def self.by_system_type(type)
271
309
  joins(:system).where('systems.system_type_id = ?', type.id)
272
310
  end
273
311
 
312
+ ##
313
+ #Filters by command name
274
314
  def self.by_command_name(cmd)
275
315
  where('job_summaries.command_name = ?', cmd)
276
316
  end
277
317
 
318
+ ##
319
+ #Attempts to find a JobSummary with the given date, user_id, system_id, and command_name
320
+ #
321
+ #If one does not exist, a new JobSummary will be instantiated (but not saved to the database).
278
322
  def self.find_or_new(date, user_id, system_id, command_name)
279
323
  str = by_date(date).where(:user_id => user_id, :system_id => system_id).by_command_name(command_name).to_sql
280
324
  summary = by_date(date).where(:user_id => user_id, :system_id => system_id).by_command_name(command_name).first
@@ -287,27 +331,62 @@ module Bookie
287
331
  summary
288
332
  end
289
333
 
334
+ ##
335
+ #Create cached summaries for the given date
336
+ #
337
+ #The date is interpreted as being in UTC.
338
+ #
339
+ #If there is nothing to summarize, a dummy summary will be created.
340
+ #
341
+ #Uses Lock::synchronize internally; should not be used in transaction blocks
290
342
  def self.summarize(date)
291
343
  jobs = Job
292
344
  unscoped = self.unscoped
293
- time_range = date.to_time ... (date + 1).to_time
345
+ time_min = date.to_utc_time
346
+ time_range = time_min ... time_min + 1.days
294
347
  day_jobs = jobs.by_time_range_inclusive(time_range)
348
+
349
+ #Find the sets of unique values.
295
350
  value_sets = day_jobs.select('user_id, system_id, command_name').uniq
296
- value_sets.each do |set|
297
- summary_jobs = jobs.where(:user_id => set.user_id).where(:system_id => set.system_id).by_command_name(set.command_name)
298
- summary = summary_jobs.summary(time_range)
351
+ if value_sets.empty?
352
+ user = User.select(:id).first
353
+ system = System.select(:id).first
354
+ #If there are no users or no systems, we can't create the dummy summary, so just return.
355
+ return unless user && system
356
+ #Create a dummy summary so summary() doesn't keep trying to create one.
299
357
  Lock[:job_summaries].synchronize do
300
- sum = unscoped.find_or_new(date, set.user_id, set.system_id, set.command_name)
301
- #To consider: do we even need this field? (Time-range summaries don't use it because of overlap issues.)
302
- sum.num_jobs = summary[:jobs].length
303
- sum.cpu_time = summary[:cpu_time]
304
- sum.memory_time = summary[:memory_time]
305
- sum.successful = summary[:successful]
358
+ sum = unscoped.find_or_new(date, user.id, system.id, '')
359
+ sum.cpu_time = 0
360
+ sum.memory_time = 0
306
361
  sum.save!
307
362
  end
363
+ else
364
+ value_sets.each do |set|
365
+ summary_jobs = jobs.where(:user_id => set.user_id).where(:system_id => set.system_id).by_command_name(set.command_name)
366
+ summary = summary_jobs.summary(time_range)
367
+ Lock[:job_summaries].synchronize do
368
+ sum = unscoped.find_or_new(date, set.user_id, set.system_id, set.command_name)
369
+ sum.cpu_time = summary[:cpu_time]
370
+ sum.memory_time = summary[:memory_time]
371
+ sum.save!
372
+ end
373
+ end
308
374
  end
309
375
  end
310
376
 
377
+ ##
378
+ #Returns a summary of jobs in the database
379
+ #
380
+ #The following options are supported:
381
+ #- [<tt>:range</tt>] restricts the summary to a specific time interval (specified as a Range of Time objects)
382
+ #- [<tt>:jobs</tt>] the jobs on which the summary should operate
383
+ #
384
+ #Internally, this may call JobSummary::summary, which uses Lock#synchronize, so this should not be used inside a transaction block.
385
+ #
386
+ #When filtering, the same filters must be applied to both the Jobs and the JobSummaries. For example:
387
+ # jobs = Bookie::Database::Job.by_user_name('root')
388
+ # summaries = Bookie::Database::Job.by_user_name('root')
389
+ # puts summaries.summary(:jobs => jobs)
311
390
  def self.summary(opts = {})
312
391
  jobs = opts[:jobs] || Job
313
392
  range = opts[:range]
@@ -323,7 +402,7 @@ module Bookie
323
402
  first_started_system = System.order(:start_time).first
324
403
  range = first_started_system.start_time ... end_time
325
404
  else
326
- range = Date.new ... Date.new
405
+ range = Time.new ... Time.new
327
406
  end
328
407
  end
329
408
  range = range.normalized
@@ -333,54 +412,66 @@ module Bookie
333
412
  memory_time = 0
334
413
  successful = 0
335
414
 
336
- #Could there be partial days at the beginning/end?
337
- date_range = range
338
- unless range.begin.kind_of?(Date) && range.end.kind_of?(Date)
339
- date_begin = range.begin.to_date
340
- unless date_begin.to_time == range.begin
341
- date_begin += 1
342
- time_before_max = [date_begin.to_time, range.end].min
343
- time_before_min = range.begin
344
- summary = jobs.summary(time_before_min ... time_before_max)
415
+ #Is the beginning somewhere between days?
416
+ date_begin = range.begin.utc.to_date
417
+ unless date_begin.to_utc_time == range.begin
418
+ date_begin += 1
419
+ time_before_max = [date_begin.to_utc_time, range.end].min
420
+ time_before_min = range.begin
421
+ summary = jobs.summary(time_before_min ... time_before_max)
422
+ cpu_time += summary[:cpu_time]
423
+ memory_time += summary[:memory_time]
424
+ end
425
+
426
+ #Is the end somewhere between days?
427
+ date_end = range.end.utc.to_date
428
+ time_after_min = date_end.to_utc_time
429
+ unless time_after_min <= range.begin
430
+ time_after_max = range.end
431
+ time_after_range = Range.new(time_after_min, time_after_max, range.exclude_end?)
432
+ unless time_after_range.empty?
433
+ summary = jobs.summary(time_after_range)
345
434
  cpu_time += summary[:cpu_time]
346
435
  memory_time += summary[:memory_time]
347
- successful += summary[:successful]
348
436
  end
349
-
350
- date_end = range.end.to_date
351
- time_after_min = date_end.to_time
352
- unless time_after_min <= range.begin
353
- time_after_max = range.end
354
- time_after_range = Range.new(time_after_min, time_after_max, range.exclude_end?)
355
- unless time_after_range.empty?
356
- summary = jobs.summary(time_after_range)
357
- cpu_time += summary[:cpu_time]
358
- memory_time += summary[:memory_time]
359
- successful += summary[:successful]
360
- end
361
- end
362
-
363
- date_range = date_begin ... date_end
364
437
  end
365
438
 
439
+ date_range = date_begin ... date_end
440
+
366
441
  unscoped = self.unscoped
367
- date = date_range.begin
368
- while date_range.cover?(date) do
369
- #To do: what if there aren't any summaries to be made? Will we just run summarize() each time?
370
- summarize(date) if unscoped.by_date(date).empty?
371
- summaries = by_date(date)
372
- summaries.all.each do |summary|
373
- cpu_time += summary.cpu_time
374
- memory_time += summary.memory_time
375
- successful += summary.successful
376
- end
377
- date += 1
442
+ summaries = by_date_range(date_range).order(:date).all
443
+ index = 0
444
+ date_range.each do |date|
445
+ new_index = index
446
+ sum = summaries[new_index]
447
+ while sum && sum.date == date do
448
+ cpu_time += sum.cpu_time
449
+ memory_time += sum.memory_time
450
+ new_index += 1
451
+ sum = summaries[new_index]
452
+ end
453
+ #Did we actually process any summaries?
454
+ if new_index == index
455
+ #Nope. Create the summaries.
456
+ #To consider: optimize out the query?
457
+ unscoped.summarize(date)
458
+ sums = by_date(date)
459
+ sums.each do |sum|
460
+ cpu_time += sum.cpu_time
461
+ memory_time += sum.memory_time
462
+ end
463
+ end
378
464
  end
379
465
 
380
- time_range = Range.new(range.begin.to_time, range.end.to_time, range.exclude_end?)
381
- jobs = jobs.by_time_range_inclusive(time_range)
382
- num_jobs = (range && range.empty?) ? 0 : jobs.count
383
-
466
+ if range && range.empty?
467
+ num_jobs = 0
468
+ successful = 0
469
+ else
470
+ jobs = jobs.by_time_range_inclusive(range)
471
+ num_jobs = jobs.count
472
+ successful = jobs.where('jobs.exit_code = 0').count
473
+ end
474
+
384
475
  {
385
476
  :num_jobs => num_jobs,
386
477
  :cpu_time => cpu_time,
@@ -389,19 +480,19 @@ module Bookie
389
480
  }
390
481
  end
391
482
 
392
- validates_presence_of :user_id, :system_id, :date, :num_jobs, :cpu_time, :memory_time, :successful
483
+ validates_presence_of :user_id, :system_id, :date, :cpu_time, :memory_time
393
484
 
394
485
  validates_each :command_name do |record, attr, value|
395
486
  record.errors.add(attr, 'must not be nil') if value == nil
396
487
  end
397
488
 
398
- validates_each :num_jobs, :cpu_time, :memory_time, :successful do |record, attr, value|
489
+ validates_each :cpu_time, :memory_time do |record, attr, value|
399
490
  record.errors.add(attr, 'must be a non-negative integer') unless value && value >= 0
400
491
  end
401
492
  end
402
493
 
403
494
  ##
404
- #A group
495
+ #A group of users
405
496
  class Group < ActiveRecord::Base
406
497
  has_many :users
407
498
 
@@ -488,13 +579,26 @@ module Bookie
488
579
  where('systems.system_type_id = ?', sys_type.id)
489
580
  end
490
581
 
582
+ ##
583
+ #Finds all systems whose running intervals overlap the given time range
584
+ #
585
+ #To do: unit test.
586
+ def self.by_time_range_inclusive(time_range)
587
+ if time_range.empty?
588
+ where('1=0')
589
+ elsif time_range.exclude_end?
590
+ where('(? <= systems.end_time OR systems.end_time IS NULL) AND systems.start_time < ?', time_range.first, time_range.last)
591
+ else
592
+ where('(? <= systems.end_time OR systems.end_time IS NULL) AND systems.start_time <= ?', time_range.first, time_range.last)
593
+ end
594
+ end
595
+
491
596
  ##
492
597
  #Finds the current system for a given sender and time
493
598
  #
494
599
  #This method also checks that this system's specifications are the same as those in the database and raises an error if they are different.
495
600
  #
496
601
  #This uses Lock#synchronize internally, so it probably should not be called within a transaction block.
497
- #
498
602
  def self.find_current(sender, time = nil)
499
603
  time ||= Time.now
500
604
  config = sender.config
@@ -516,19 +620,42 @@ Please make sure that all previous systems with this hostname have been marked a
516
620
  system
517
621
  end
518
622
 
623
+ ##
624
+ #Returns an array of all systems, pre-loading relations to reduce the need for extra queries
625
+ #
626
+ #Relations are not cached between calls.
627
+ def self.all_with_relations
628
+ systems = all
629
+ system_types = {}
630
+ systems.each do |system|
631
+ system_type = system_types[system.system_type_id]
632
+ if system_type
633
+ system.system_type = system_type
634
+ else
635
+ system_type = system.system_type
636
+ system_types[system_type.id] = system_type
637
+ end
638
+ end
639
+ systems
640
+ end
641
+
519
642
  ##
520
643
  #Produces a summary of all the systems for the given time interval
521
644
  #
522
645
  #Returns a hash with the following fields:
523
- #- <tt>:avail_cpu_time</tt>: the total CPU time available for the interval
524
- #- <tt>:avail_memory_time</tt>: the total amount of memory-time available (in kilobyte-seconds)
525
- #- <tt>:avail_memory_avg</tt>: the average amount of memory available (in kilobytes)
646
+ #- [<tt>:systems</tt>] an array containing all systems that are active in the interval
647
+ #- [<tt>:avail_cpu_time</tt>] the total CPU time available for the interval
648
+ #- [<tt>:avail_memory_time</tt>] the total amount of memory-time available (in kilobyte-seconds)
649
+ #- [<tt>:avail_memory_avg</tt>] the average amount of memory available (in kilobytes)
526
650
  #
527
651
  #To consider: include the start/end times for the summary (especially if they aren't provided as arguments)?
528
652
  #
529
- #To do: make this and other summaries operate differently on inclusive and exclusive ranges.
530
- #(Current behavior is as if the range were always exclusive.)
653
+ #Notes:
654
+ #
655
+ #Results may be slightly off when an inclusive range is used.
656
+ #To consider: is this worth fixing?
531
657
  def self.summary(time_range = nil)
658
+ #To consider: how to handle time zones with Rails apps?
532
659
  current_time = Time.now
533
660
  #Sums that are actually returned
534
661
  avail_cpu_time = 0
@@ -537,14 +664,13 @@ Please make sure that all previous systems with this hostname have been marked a
537
664
  systems = System
538
665
  if time_range
539
666
  time_range = time_range.normalized
540
- #To consider: optimize as union of queries?
541
- systems = systems.where(
542
- 'systems.start_time < ? AND (systems.end_time IS NULL OR systems.end_time > ?)',
543
- time_range.last,
544
- time_range.first)
667
+ #To do: unit test.
668
+ systems = systems.by_time_range_inclusive(time_range)
545
669
  end
670
+
671
+ all_systems = systems.all_with_relations
546
672
 
547
- systems.all.each do |system|
673
+ all_systems.each do |system|
548
674
  system_start_time = system.start_time
549
675
  system_end_time = system.end_time
550
676
  #Is there a time range constraint?
@@ -579,6 +705,7 @@ Please make sure that all previous systems with this hostname have been marked a
579
705
  end
580
706
 
581
707
  {
708
+ :systems => all_systems,
582
709
  :avail_cpu_time => avail_cpu_time,
583
710
  :avail_memory_time => avail_memory_time,
584
711
  :avail_memory_avg => if wall_time_range == 0 then 0.0 else Float(avail_memory_time) / wall_time_range end,
@@ -721,7 +848,6 @@ Please make sure that all previous systems with this hostname have been marked a
721
848
  t.datetime :start_time, :null => false
722
849
  t.datetime :end_time
723
850
  t.integer :cores, :null => false
724
- #To consider: replace with a float? (more compact)
725
851
  t.integer :memory, :null => false, :limit => 8
726
852
  end
727
853
  change_table :systems do |t|
@@ -766,12 +892,14 @@ Please make sure that all previous systems with this hostname have been marked a
766
892
  t.integer :memory, :null => false
767
893
  t.integer :exit_code, :null => false
768
894
  end
895
+ #To do: more indices?
769
896
  change_table :jobs do |t|
770
897
  t.index :user_id
771
898
  t.index :system_id
772
899
  t.index :command_name
773
900
  t.index :start_time
774
901
  t.index :end_time
902
+ t.index :exit_code
775
903
  end
776
904
  end
777
905
 
@@ -787,13 +915,10 @@ Please make sure that all previous systems with this hostname have been marked a
787
915
  t.references :system, :null => false
788
916
  t.date :date, :null => false
789
917
  t.string :command_name, :null => false
790
- t.integer :num_jobs, :null => false
791
918
  t.integer :cpu_time, :null => false
792
919
  t.integer :memory_time, :null => false
793
- t.integer :successful, :null => false
794
920
  end
795
921
  change_table :job_summaries do |t|
796
- #To consider: reorder for optimum efficiency?
797
922
  t.index [:date, :user_id, :system_id, :command_name], :unique => true, :name => 'identity'
798
923
  t.index :command_name
799
924
  t.index :date
@@ -857,63 +982,4 @@ Please make sure that all previous systems with this hostname have been marked a
857
982
  end
858
983
  end
859
984
 
860
- ##
861
- #Reopened to add some useful methods
862
- class Range
863
- ##
864
- #If end < begin, returns an empty range (begin ... begin)
865
- #Otherwise, returns the original range
866
- def normalized
867
- return self.begin ... self.begin if self.end < self.begin
868
- self
869
- end
870
-
871
- ##
872
- #Returns the empty status of the range
873
- #
874
- #A range is empty if end < begin or if begin == end and exclude_end? is true.
875
- def empty?
876
- (self.end < self.begin) || (exclude_end? && (self.begin == self.end))
877
- end
878
985
 
879
- #This code probably works, but we're not using it anywhere.
880
- # def intersection(other)
881
- # self_n = self.normalized
882
- # other = other.normalized
883
- #
884
- # new_begin, new_end, exclude_end = nil
885
- #
886
- # if self_n.cover?(other.begin)
887
- # new_first = other.begin
888
- # elsif other.cover?(self_n.begin)
889
- # new_first = self_n.begin
890
- # end
891
- #
892
- # return self_n.begin ... self_n.begin unless new_first
893
- #
894
- # if self_n.cover?(other.end)
895
- # unless other.exclude_end? && other.end == self_n.begin
896
- # new_end = other.end
897
- # exclude_end = other.exclude_end?
898
- # end
899
- # elsif other.cover?(self_n.end)
900
- # unless self_n.exclude_end? && self_n.end == other.begin
901
- # new_end = self_n.end
902
- # exclude_end = self_n.exclude_end?
903
- # end
904
- # end
905
- #
906
- # #If we still haven't found new_end, try one more case:
907
- # unless new_end
908
- # if self_n.end == other.end
909
- # #We'll only get here if both ranges exclude their ends and have the same end.
910
- # new_end = self_n.end
911
- # exclude_end = true
912
- # end
913
- # end
914
- #
915
- # return self_n.begin ... self_n.begin unless new_end
916
- #
917
- # Range.new(new_begin, new_end, exclude_end)
918
- # end
919
- end