created_id 1.1.0 → 1.1.2

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: afdabbea09a01fe7c621831292070fdd0138f39ef7efce693745765c5f468fdd
4
- data.tar.gz: 6f48cde24728a3f4b92b3c1a003e092f92d4f781e22a68350bb504a210e35f81
3
+ metadata.gz: 41989b3e85e6a81b89589bbc05a24f9e7bfcba4c47a974964ec5dfa96b6268f0
4
+ data.tar.gz: 1fb19ba275fbfff80f75bbc9be0242597f1997989922420eb525e1a93b84cf2c
5
5
  SHA512:
6
- metadata.gz: 500a45fdf791907a43d856c4315988343f69ecd571b294cdab0b5f83c347949fafba13e706e0bcfb2951342a96529a2b2d2a62ffc02982269c25f4b88b9c7003
7
- data.tar.gz: 5f6ab7875355ffb9f15495c0d0d7ecc83fd01f1a726c734274bc922511a9acdaadcf831b71cc3e0fa3612581a450e4f561d99c0e0b5a87b3b675b99f4087bc69
6
+ metadata.gz: 7ef459aad9deec426b20ae18867380b880126730351d677f8b7e15d8f07900566d5fc7fc779845e9b047131584dbc2b59f1d8854bdb8f52d7f38fe5697fdf11e
7
+ data.tar.gz: b8016654eeb70c0b750ebefcc497aedacad8fe49f994c716fcce278ab61041c6ff0f9f0b140ab437760cd85aad25c8927e48e24394a49529fd31bd6291346f9b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.1.2
8
+
9
+ ### Fixed
10
+
11
+ - Fixed data corruption when re-running `index_ids_for` on an hour that had already been indexed. The range calculation was using the hour's own stored range as a bound, which excluded the hour's true minimum id (and could exclude the maximum) and caused records to be silently dropped from `created_between`, `created_after`, and `created_before` query results. Recalculating an indexed hour is now safe.
12
+ - The upper bound used when calculating an hour's id range now always skips the immediately following hour, since adjacent hours' id ranges can legitimately overlap when ids are slightly out of order near an hour boundary.
13
+ - Fixed a race condition in `index_ids_for` where two processes indexing the same class and hour concurrently could raise `ActiveRecord::RecordNotUnique`. The save is now retried so the losing process updates the existing row.
14
+ - Fixed misspelled `ArgumentError` constant that caused a `NameError` when including `CreatedId` in a non-ActiveRecord class.
15
+
16
+ ### Added
17
+
18
+ - Creating a record with an explicitly backdated `created_at` in an hour whose id range has already been indexed now raises `CreatedId::CreatedAtChangedError` and rolls back the insert, since the record's id would fall outside the stored range and be invisible to range queries.
19
+
20
+ ## 1.1.1
21
+
22
+ ### Fixed
23
+
24
+ - Fixed issue with finding id ranges that prevented the query from using the indexed ids.
7
25
  ## 1.1.0
8
26
 
9
27
  ### Changed
data/README.md CHANGED
@@ -111,12 +111,14 @@ Task.where(user_id: 1000).created_before(7.days.ago)
111
111
  Task.created_between(25.hours.ago, 24.hours.ago)
112
112
  ```
113
113
 
114
- You'll then need to set up a periodic task to store the id ranges for your models. For each model that includes `CreatedId`, you need to run the `index_ids_for` once per hour. This task should be run shortly after the top of the hour.
114
+ You'll then need to set up a periodic task to store the id ranges for your models. For each model that includes `CreatedId`, you need to run the `index_ids_for` once per hour. This task should be run shortly after the top of the hour, but leave a few minutes of buffer — longer than your longest-running write transaction. A transaction that starts at the end of an hour but commits after the indexer has scanned that hour would otherwise not be visible to the indexer, and if that row happens to be the hour's minimum or maximum id, it would fall outside the stored range and be missed by queries.
115
115
 
116
116
  ```ruby
117
117
  Task.index_ids_for(1.hour.ago)
118
118
  ```
119
119
 
120
+ Re-running `index_ids_for` for an hour that has already been indexed is safe and recalculates the stored range from the current data. As a self-healing measure, you can periodically re-index the last few hours to pick up any rows that were committed after their hour was first indexed.
121
+
120
122
  Finally, you'll need to run a script to calculate the id ranges for all of your existing data.
121
123
 
122
124
  ```ruby
@@ -130,7 +132,9 @@ end
130
132
 
131
133
  If an ID range is missing for a specific hour, your queries will still function, but with a broader range of IDs. You can recalculate missing ranges at any time to improve efficiency.
132
134
 
133
- There is an additional requirement for using this gem that you do not change the `created_at` value after a row is inserted since this can mess up the assumption about the correlation between ids and `created_at` timestamps. An error will be thrown if you try to change a record's timestamp after the id range has been created. The query logic can handle small variations between id order and timestamp order (i.e. if id 1000 has a timestamp a few seconds after id 1001).
135
+ There is an additional requirement for using this gem that you do not change the `created_at` value after a row is inserted since this can mess up the assumption about the correlation between ids and `created_at` timestamps. An error will be thrown if you try to change a record's timestamp after the id range has been created, or if you insert a new record with a backdated `created_at` in an hour whose id range has already been indexed. The query logic can handle small variations between id order and timestamp order (i.e. if id 1000 has a timestamp a few seconds after id 1001).
136
+
137
+ Note that these guards can only check hours that have already been indexed. Changing `created_at` across hours (or inserting backdated rows) into an hour that has *not* been indexed can still leave the row outside the id bounds inferred from neighboring indexed hours. If you need to backfill or correct historical data, re-run `index_ids_for` for the affected hours afterwards.
134
138
 
135
139
  ## Installation
136
140
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.1.2
@@ -68,18 +68,21 @@ module CreatedId
68
68
  klass = klass.base_class
69
69
  hour = CreatedId.coerce_hour(time)
70
70
  next_hour = hour + 3600
71
- prev_hour = hour - 3600
72
71
 
73
72
  finder = klass.unscoped.where(created_at: (hour...next_hour))
74
73
 
75
- prev_id = CreatedId::IdRange.min_id(self, prev_hour)
76
- if prev_id
77
- finder = finder.where(klass.arel_table[:id].gt(prev_id)) if prev_id > 0
78
-
79
- next_id = CreatedId::IdRange.min_id(self, next_hour + 3600)
80
- if next_id
81
- finder = finder.where(klass.arel_table[:id].lt(next_id)) if next_id > prev_id
82
- end
74
+ # Lower bound: the min id of the latest indexed hour strictly before this one.
75
+ # A record in this hour can never have an id at or below the previous indexed
76
+ # hour's minimum, even when ids are out of order near the boundary.
77
+ prev_id = for_class(klass).where(hour: nil...hour).order(hour: :desc).first&.min_id
78
+ finder = finder.where(klass.arel_table[:id].gt(prev_id)) if prev_id && prev_id > 0
79
+
80
+ # Upper bound: the min id of the earliest indexed hour at least two hours after
81
+ # this one. The immediately following hour is skipped because its id range may
82
+ # legitimately overlap this hour's when ids are out of order near the boundary.
83
+ next_id = for_class(klass).created_after(next_hour + 3600).order(hour: :asc).first&.min_id
84
+ if next_id && (prev_id.nil? || next_id > prev_id)
85
+ finder = finder.where(klass.arel_table[:id].lt(next_id))
83
86
  end
84
87
 
85
88
  [finder.minimum(:id), finder.maximum(:id)]
@@ -93,10 +96,18 @@ module CreatedId
93
96
  # @param max_id [Integer] The maximum id for the class created in the given hour.
94
97
  # @return [void]
95
98
  def save_created_id(klass, time, min_id, max_id)
96
- record = find_or_initialize_by(class_name: klass.base_class.name, hour: CreatedId.coerce_hour(time))
97
- record.min_id = min_id
98
- record.max_id = max_id
99
- record.save!
99
+ retried = false
100
+ begin
101
+ record = find_or_initialize_by(class_name: klass.base_class.name, hour: CreatedId.coerce_hour(time))
102
+ record.min_id = min_id
103
+ record.max_id = max_id
104
+ record.save!
105
+ rescue ActiveRecord::RecordNotUnique
106
+ # A concurrent process indexed the same hour first; retry to update its record.
107
+ raise if retried
108
+ retried = true
109
+ retry
110
+ end
100
111
  end
101
112
  end
102
113
 
data/lib/created_id.rb CHANGED
@@ -21,13 +21,14 @@ module CreatedId
21
21
 
22
22
  included do
23
23
  unless defined?(ActiveRecord) && self < ActiveRecord::Base
24
- raise ArgmentError, "CreatedId can only be included in ActiveRecord models"
24
+ raise ArgumentError, "CreatedId can only be included in ActiveRecord models"
25
25
  end
26
26
 
27
27
  scope :created_after, ->(time) { created_between(time, nil) }
28
28
  scope :created_before, ->(time) { created_between(nil, time) }
29
29
 
30
30
  before_save :verify_created_at_created_id!, if: :created_at_changed?
31
+ after_create :verify_created_id_on_backdated_create!
31
32
  end
32
33
 
33
34
  class_methods do
@@ -71,11 +72,30 @@ module CreatedId
71
72
  # This is the normal case where created at is set to the current time on insert.
72
73
  return if id.nil? && created_at_was.nil?
73
74
 
74
- new_hour = CreatedId.coerce_hour(created_at || Time.now)
75
- range = CreatedId::IdRange.for_class(self.class).find_by(hour: new_hour)
75
+ verify_id_in_indexed_range!("created_at cannot be changed outside of the range of the created_ids for that time period")
76
+ end
77
+
78
+ # Verify that a record created with an explicit created_at in an hour that has already
79
+ # been indexed received an id within the stored range. The id is not known until after
80
+ # the insert, so this check must run after create; raising here rolls back the insert.
81
+ # Records created in the current hour skip the check since that hour cannot have been
82
+ # indexed yet.
83
+ #
84
+ # @return [void]
85
+ # @raise [CreatedId::CreatedAtChangedError] If the record's id is outside the range of the created_ids for its hour.
86
+ def verify_created_id_on_backdated_create!
87
+ return if created_at.nil?
88
+ return if CreatedId.coerce_hour(created_at) == CreatedId.coerce_hour(Time.now)
89
+
90
+ verify_id_in_indexed_range!("created_at cannot be set to an hour whose id range has already been indexed")
91
+ end
92
+
93
+ def verify_id_in_indexed_range!(message)
94
+ hour = CreatedId.coerce_hour(created_at || Time.now)
95
+ range = CreatedId::IdRange.for_class(self.class).find_by(hour: hour)
76
96
 
77
97
  if range && (id < range.min_id || id > range.max_id)
78
- raise CreatedAtChangedError, "created_at cannot be changed outside of the range of the created_ids for that time period"
98
+ raise CreatedAtChangedError, message
79
99
  end
80
100
  end
81
101
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: created_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-11-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -38,7 +37,6 @@ dependencies:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
41
- description:
42
40
  email:
43
41
  - bbdurand@gmail.com
44
42
  executables: []
@@ -62,7 +60,6 @@ metadata:
62
60
  homepage_uri: https://github.com/bdurand/created_id
63
61
  source_code_uri: https://github.com/bdurand/created_id
64
62
  changelog_uri: https://github.com/bdurand/created_id/blob/main/CHANGELOG.md
65
- post_install_message:
66
63
  rdoc_options: []
67
64
  require_paths:
68
65
  - lib
@@ -77,8 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
74
  - !ruby/object:Gem::Version
78
75
  version: '0'
79
76
  requirements: []
80
- rubygems_version: 3.4.10
81
- signing_key:
77
+ rubygems_version: 4.0.3
82
78
  specification_version: 4
83
79
  summary: Optimize ActiveRecord queries for filtering large tables on the created_at
84
80
  column by pre-computing id ranges.