partition_gardener 0.3.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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +203 -0
  5. data/SECURITY.md +27 -0
  6. data/docs/THIRD_PARTY_LICENSE_MANIFEST.tsv +60 -0
  7. data/docs/application_contract.md +82 -0
  8. data/docs/audit_reference.md +125 -0
  9. data/docs/background_job.md +161 -0
  10. data/docs/cli.md +71 -0
  11. data/docs/configuration.md +261 -0
  12. data/docs/cutover.md +180 -0
  13. data/docs/decision_flow.md +154 -0
  14. data/docs/host_testing.md +91 -0
  15. data/docs/monitoring.md +110 -0
  16. data/docs/naming.md +71 -0
  17. data/docs/operations.md +131 -0
  18. data/docs/partition_landscape.md +323 -0
  19. data/docs/pg_party_recipe.md +44 -0
  20. data/docs/related_postgres_tooling.md +195 -0
  21. data/docs/retention.md +65 -0
  22. data/docs/schemas/partition_garden.schema.json +114 -0
  23. data/docs/schemas/plan_report.schema.json +82 -0
  24. data/docs/tooling_split.md +79 -0
  25. data/exe/partition_gardener +6 -0
  26. data/lib/partition_gardener/active_record_run_record_store.rb +1 -0
  27. data/lib/partition_gardener/advisory_lock.rb +48 -0
  28. data/lib/partition_gardener/archive_retention.rb +88 -0
  29. data/lib/partition_gardener/audit.rb +93 -0
  30. data/lib/partition_gardener/blank.rb +13 -0
  31. data/lib/partition_gardener/cli.rb +190 -0
  32. data/lib/partition_gardener/config_document.rb +240 -0
  33. data/lib/partition_gardener/configuration.rb +102 -0
  34. data/lib/partition_gardener/connection.rb +260 -0
  35. data/lib/partition_gardener/date_bucket.rb +117 -0
  36. data/lib/partition_gardener/date_calendar.rb +65 -0
  37. data/lib/partition_gardener/date_range_maintenance.rb +297 -0
  38. data/lib/partition_gardener/default_partition.rb +54 -0
  39. data/lib/partition_gardener/executor.rb +324 -0
  40. data/lib/partition_gardener/gap_detection.rb +59 -0
  41. data/lib/partition_gardener/hash_routing.rb +70 -0
  42. data/lib/partition_gardener/layout/calendar_year.rb +28 -0
  43. data/lib/partition_gardener/layout/integer_window.rb +28 -0
  44. data/lib/partition_gardener/layout/sliding_window.rb +28 -0
  45. data/lib/partition_gardener/layout/three_area.rb +27 -0
  46. data/lib/partition_gardener/layout/zone_segments.rb +62 -0
  47. data/lib/partition_gardener/lock_not_acquired.rb +3 -0
  48. data/lib/partition_gardener/maintenance_backend.rb +73 -0
  49. data/lib/partition_gardener/memory_run_record_store.rb +19 -0
  50. data/lib/partition_gardener/migration/hot_switch_concern.rb +445 -0
  51. data/lib/partition_gardener/missing_conflict_index.rb +3 -0
  52. data/lib/partition_gardener/naming.rb +29 -0
  53. data/lib/partition_gardener/orphaned_rebalance_staging.rb +3 -0
  54. data/lib/partition_gardener/pg_connection.rb +94 -0
  55. data/lib/partition_gardener/plan.rb +49 -0
  56. data/lib/partition_gardener/plan_applier.rb +289 -0
  57. data/lib/partition_gardener/plan_diff.rb +51 -0
  58. data/lib/partition_gardener/plan_report.rb +95 -0
  59. data/lib/partition_gardener/planner.rb +21 -0
  60. data/lib/partition_gardener/predicate.rb +85 -0
  61. data/lib/partition_gardener/premake_monthly_maintenance.rb +44 -0
  62. data/lib/partition_gardener/rails.rb +12 -0
  63. data/lib/partition_gardener/registry.rb +84 -0
  64. data/lib/partition_gardener/run_failed.rb +10 -0
  65. data/lib/partition_gardener/run_metrics.rb +65 -0
  66. data/lib/partition_gardener/run_record.rb +76 -0
  67. data/lib/partition_gardener/sql_run_record_store.rb +106 -0
  68. data/lib/partition_gardener/stdlib_extensions.rb +15 -0
  69. data/lib/partition_gardener/strategy/composite.rb +27 -0
  70. data/lib/partition_gardener/strategy/cursor_columns.rb +18 -0
  71. data/lib/partition_gardener/strategy/date_range.rb +303 -0
  72. data/lib/partition_gardener/strategy/hash_branches.rb +161 -0
  73. data/lib/partition_gardener/strategy/integer_range.rb +261 -0
  74. data/lib/partition_gardener/strategy/list_split.rb +125 -0
  75. data/lib/partition_gardener/strategy/requires_default_partition.rb +19 -0
  76. data/lib/partition_gardener/strategy.rb +26 -0
  77. data/lib/partition_gardener/templates.rb +373 -0
  78. data/lib/partition_gardener/unmoved_rows_remaining.rb +15 -0
  79. data/lib/partition_gardener/version.rb +3 -0
  80. data/lib/partition_gardener.rb +215 -0
  81. data/sig/partition_gardener.rbs +19 -0
  82. metadata +367 -0
@@ -0,0 +1,65 @@
1
+ module PartitionGardener
2
+ class RunMetrics
3
+ attr_reader :table_name, :started_at, :rows_moved, :finished_at, :skipped, :skip_reason
4
+ attr_accessor :plan_signature
5
+
6
+ def initialize(table_name)
7
+ @table_name = table_name
8
+ @started_at = monotonic_clock
9
+ @plan_signature = nil
10
+ @rows_moved = 0
11
+ @finished_at = nil
12
+ @skipped = false
13
+ @skip_reason = nil
14
+ end
15
+
16
+ def add_rows(count)
17
+ @rows_moved += count.to_i
18
+ end
19
+
20
+ def mark_skipped!(reason)
21
+ @skipped = true
22
+ @skip_reason = reason
23
+ finish!
24
+ end
25
+
26
+ def finish!
27
+ @finished_at = monotonic_clock
28
+ end
29
+
30
+ def duration_ms
31
+ return nil unless @finished_at
32
+
33
+ ((@finished_at - @started_at) * 1000).round
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ table_name: table_name,
39
+ duration_ms: duration_ms,
40
+ plan_signature: plan_signature,
41
+ rows_moved: rows_moved,
42
+ skipped: skipped,
43
+ skip_reason: skip_reason
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def monotonic_clock
50
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ end
52
+ end
53
+
54
+ RunSummary = Data.define(:tables, :errors) do
55
+ SCHEMA_VERSION = "1.0"
56
+
57
+ def to_h
58
+ {
59
+ schema_version: SCHEMA_VERSION,
60
+ tables: tables.map(&:to_h),
61
+ errors: errors.map { |error| error.message }
62
+ }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,76 @@
1
+ module PartitionGardener
2
+ class RunRecord
3
+ PHASES = %w[detach segments rows cleanup complete].freeze
4
+
5
+ attr_reader :table_name, :phase, :plan_signature, :staging_row_count
6
+
7
+ def self.load(table_name)
8
+ attributes = PartitionGardener.configuration.run_record_store.load(table_name)
9
+ return unless attributes
10
+
11
+ from_h(attributes)
12
+ end
13
+
14
+ def self.from_h(attributes)
15
+ new(
16
+ table_name: fetch_attribute(attributes, :table_name),
17
+ phase: fetch_attribute(attributes, :phase),
18
+ plan_signature: fetch_attribute(attributes, :plan_signature),
19
+ staging_row_count: fetch_attribute(attributes, :staging_row_count) || 0
20
+ )
21
+ end
22
+
23
+ def self.fetch_attribute(attributes, key)
24
+ attributes[key] || attributes[key.to_s]
25
+ end
26
+
27
+ def self.clear(table_name)
28
+ PartitionGardener.configuration.run_record_store.clear(table_name)
29
+ end
30
+
31
+ def self.start(table_name:, plan_signature:)
32
+ new(
33
+ table_name: table_name,
34
+ phase: "detach",
35
+ plan_signature: plan_signature,
36
+ staging_row_count: 0
37
+ ).tap(&:save!)
38
+ end
39
+
40
+ def initialize(table_name:, phase:, plan_signature:, staging_row_count: 0)
41
+ @table_name = table_name
42
+ @phase = phase
43
+ @plan_signature = plan_signature
44
+ @staging_row_count = staging_row_count
45
+ end
46
+
47
+ def incomplete?
48
+ Blank.present?(phase) && phase != "complete"
49
+ end
50
+
51
+ def save!
52
+ PartitionGardener.configuration.run_record_store.save(
53
+ table_name,
54
+ {
55
+ table_name: table_name,
56
+ phase: phase,
57
+ plan_signature: plan_signature,
58
+ staging_row_count: staging_row_count
59
+ }
60
+ )
61
+ end
62
+
63
+ def advance!(phase, staging_row_count: nil)
64
+ RunRecord.new(
65
+ table_name: table_name,
66
+ phase: phase,
67
+ plan_signature: plan_signature,
68
+ staging_row_count: staging_row_count.nil? ? self.staging_row_count : staging_row_count
69
+ ).tap(&:save!)
70
+ end
71
+
72
+ def phase_at_least?(name)
73
+ PHASES.index(phase) >= PHASES.index(name)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,106 @@
1
+ module PartitionGardener
2
+ class SqlRunRecordStore
3
+ TABLE_NAME = "partition_gardener_run_records"
4
+
5
+ def initialize
6
+ @schema_mutex = Mutex.new
7
+ @schema_ready = false
8
+ end
9
+
10
+ def load(table_name)
11
+ ensure_schema!
12
+ sql = load_sql(table_name)
13
+ row = connection.execute(sql).first
14
+
15
+ return unless row
16
+
17
+ {
18
+ table_name: row["table_name"],
19
+ phase: row["phase"],
20
+ plan_signature: row["plan_signature"],
21
+ staging_row_count: row["staging_row_count"].to_i
22
+ }
23
+ end
24
+
25
+ def save(table_name, attributes)
26
+ ensure_schema!
27
+ sql = save_sql(table_name, attributes)
28
+ connection.execute(sql)
29
+ end
30
+
31
+ def clear(table_name)
32
+ ensure_schema!
33
+ sql = clear_sql(table_name)
34
+ connection.execute(sql)
35
+ end
36
+
37
+ def ensure_schema!
38
+ return if @schema_ready
39
+
40
+ @schema_mutex.synchronize do
41
+ return if @schema_ready
42
+
43
+ connection.execute(create_table_sql)
44
+ @schema_ready = true
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def connection
51
+ PartitionGardener.configuration.connection
52
+ end
53
+
54
+ def quoted_table(name)
55
+ connection.quote_table_name(name)
56
+ end
57
+
58
+ def load_sql(table_name)
59
+ <<~SQL
60
+ SELECT table_name, phase, plan_signature, staging_row_count
61
+ FROM #{quoted_table(TABLE_NAME)}
62
+ WHERE table_name = #{connection.quote(table_name)}
63
+ SQL
64
+ end
65
+
66
+ def save_sql(table_name, attributes)
67
+ <<~SQL
68
+ INSERT INTO #{quoted_table(TABLE_NAME)} (
69
+ table_name, phase, plan_signature, staging_row_count, updated_at
70
+ ) VALUES (
71
+ #{connection.quote(table_name)},
72
+ #{connection.quote(attributes.fetch(:phase))},
73
+ #{connection.quote(attributes.fetch(:plan_signature))},
74
+ #{attributes.fetch(:staging_row_count, 0).to_i},
75
+ NOW()
76
+ )
77
+ ON CONFLICT (table_name) DO UPDATE SET
78
+ phase = EXCLUDED.phase,
79
+ plan_signature = EXCLUDED.plan_signature,
80
+ staging_row_count = EXCLUDED.staging_row_count,
81
+ updated_at = NOW()
82
+ SQL
83
+ end
84
+
85
+ def clear_sql(table_name)
86
+ <<~SQL
87
+ DELETE FROM #{quoted_table(TABLE_NAME)}
88
+ WHERE table_name = #{connection.quote(table_name)}
89
+ SQL
90
+ end
91
+
92
+ def create_table_sql
93
+ <<~SQL
94
+ CREATE TABLE IF NOT EXISTS #{quoted_table(TABLE_NAME)} (
95
+ table_name text PRIMARY KEY,
96
+ phase text NOT NULL,
97
+ plan_signature text NOT NULL,
98
+ staging_row_count integer NOT NULL DEFAULT 0,
99
+ updated_at timestamptz NOT NULL DEFAULT NOW()
100
+ )
101
+ SQL
102
+ end
103
+ end
104
+
105
+ ActiveRecordRunRecordStore = SqlRunRecordStore
106
+ end
@@ -0,0 +1,15 @@
1
+ module PartitionGardener
2
+ module IntegerDurationMethods
3
+ def minutes
4
+ self * 60
5
+ end
6
+
7
+ def seconds
8
+ self
9
+ end
10
+ end
11
+ end
12
+
13
+ unless defined?(ActiveSupport::Duration)
14
+ Integer.include(PartitionGardener::IntegerDurationMethods)
15
+ end
@@ -0,0 +1,27 @@
1
+ module PartitionGardener
2
+ class CompositeMaintenance
3
+ def initialize(config, job_class_name: "PartitionGardener", executor: nil)
4
+ @config = config
5
+ @job_class_name = job_class_name
6
+ @executor = executor || Executor.for_config(config)
7
+ end
8
+
9
+ def run!
10
+ Templates.expand(@config).each do |branch_config|
11
+ ThreeAreaMaintenance.new(branch_config, job_class_name: @job_class_name, executor: @executor).run!
12
+ end
13
+ end
14
+
15
+ def split_future_month_from_current!(_identifier = nil)
16
+ run!
17
+ end
18
+
19
+ def split_pressured_future_month_partitions
20
+ run!
21
+ end
22
+
23
+ def collapse_low_volume_future_month_partitions
24
+ run!
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ module PartitionGardener
2
+ module Strategy
3
+ module CursorColumns
4
+ def cursor_columns
5
+ base_key = partition_key_base
6
+ conflict_columns = @config[:conflict_key] || [base_key, "id"]
7
+ ([base_key] + conflict_columns).uniq
8
+ end
9
+
10
+ def partition_key_base
11
+ column = @config[:partition_key_column]
12
+ return column.to_s if column.nil?
13
+
14
+ column.to_s.split("::").first.strip
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,303 @@
1
+ module PartitionGardener
2
+ module Strategy
3
+ class DateRange
4
+ include Naming
5
+ include RequiresDefaultPartition
6
+ include CursorColumns
7
+
8
+ DEFAULT_ACTIVE_MONTHS = 12
9
+ DEFAULT_ACTIVE_YEARS = 2
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def active_window
16
+ bucket = date_bucket
17
+ active_start = DateBucket.beginning_of_bucket(today, bucket)
18
+ active_span = active_span_for(bucket)
19
+ active_end = DateBucket.add_buckets(active_start, active_span, bucket)
20
+
21
+ {start: active_start, end: active_end}
22
+ end
23
+
24
+ def build_plan
25
+ window = active_window
26
+ heatmap = collect_heatmap(window)
27
+ hot_buckets = hot_buckets_in_window(heatmap, window)
28
+
29
+ segments = if year_bucket?
30
+ Layout::CalendarYear.build_segments(
31
+ config: @config,
32
+ active_start: window[:start],
33
+ active_end: window[:end],
34
+ hot_years: hot_buckets
35
+ )
36
+ else
37
+ Layout::SlidingWindow.build_segments(
38
+ config: @config,
39
+ active_start: window[:start],
40
+ active_end: window[:end],
41
+ hot_months: hot_buckets
42
+ )
43
+ end
44
+
45
+ Plan::Result.new(segments: segments, hot_buckets: hot_buckets)
46
+ end
47
+
48
+ def attached_tail_segments
49
+ window = active_window
50
+
51
+ Connection.attached_partitions(table_name).filter_map do |partition|
52
+ next if partition.default
53
+ next unless managed_tail_partition?(partition.name, window: window)
54
+
55
+ Plan::Segment.new(
56
+ name: partition.name,
57
+ range_start: partition.range_start,
58
+ range_end: partition.range_end,
59
+ kind: segment_kind_for(partition.name, window: window)
60
+ )
61
+ end
62
+ end
63
+
64
+ def collect_heatmap(window)
65
+ bucket_counts = Hash.new(0)
66
+ dedicated_partition_counts = {}
67
+ dedicated_partitions = hot_bucket_partitions_in_window(window).to_h
68
+
69
+ heatmap_source_partitions(window).each do |partition_name|
70
+ counts = counts_by_bucket_in_partition(partition_name)
71
+ counts.each { |bucket, count| bucket_counts[bucket] += count }
72
+
73
+ bucket = dedicated_partitions[partition_name]
74
+ dedicated_partition_counts[bucket] = counts.values.sum if bucket
75
+ end
76
+
77
+ {
78
+ bucket_counts: bucket_counts,
79
+ default_rows: default_row_count,
80
+ dedicated_partition_counts: dedicated_partition_counts
81
+ }
82
+ end
83
+
84
+ def hot_buckets_in_window(heatmap, window)
85
+ return [] if rolling_current_layout?
86
+
87
+ hot_buckets = Set.new
88
+
89
+ heatmap[:bucket_counts].each do |bucket, row_count|
90
+ next unless bucket_in_window?(bucket, window)
91
+ next unless row_count >= split_row_threshold
92
+
93
+ hot_buckets << bucket
94
+ end
95
+
96
+ dedicated_counts = heatmap.fetch(:dedicated_partition_counts, {})
97
+ hot_bucket_partitions_in_window(window).each do |_partition_name, bucket|
98
+ row_count = dedicated_counts[bucket]
99
+ next if row_count.nil?
100
+
101
+ if row_count >= split_row_threshold
102
+ hot_buckets << bucket
103
+ else
104
+ hot_buckets.delete(bucket)
105
+ end
106
+ end
107
+
108
+ hot_buckets.sort
109
+ end
110
+
111
+ def managed_tail_partition_names(window: active_window)
112
+ Connection.attached_partitions(table_name).filter_map do |partition|
113
+ next if partition.default
114
+ next unless managed_tail_partition?(partition.name, window: window)
115
+
116
+ partition.name
117
+ end
118
+ end
119
+
120
+ def tail_slot_name?(partition_name)
121
+ partition_name == current_partition_name(table_name) ||
122
+ partition_name == open_partition_name(table_name) ||
123
+ partition_name == future_partition_name(table_name) ||
124
+ partition_name.match?(/^#{Regexp.escape(table_name)}_open_\d+$/)
125
+ end
126
+
127
+ def current_and_future_where_condition(window: active_window)
128
+ partition_key_column = @config[:partition_key_column]
129
+ "#{partition_key_column} >= #{connection.quote(window[:start])}::date"
130
+ end
131
+
132
+ def bucket_where_condition(bucket)
133
+ partition_key_column = @config[:partition_key_column]
134
+ start_range = beginning_of_bucket(bucket)
135
+ end_range = end_of_bucket(bucket)
136
+ "#{partition_key_column} >= #{connection.quote(start_range)}::date AND #{partition_key_column} < #{connection.quote(end_range)}::date"
137
+ end
138
+
139
+ def archive_bucket?(bucket)
140
+ beginning_of_bucket(bucket) < active_window[:start]
141
+ end
142
+
143
+ def future_bucket?(bucket)
144
+ beginning_of_bucket(bucket) > beginning_of_bucket(today)
145
+ end
146
+
147
+ def archive_bucket_from_partition_name(partition_name)
148
+ DateBucket.archive_bucket_from_partition_name(table_name, partition_name, date_bucket)
149
+ end
150
+
151
+ def segment_for_values_clause(segment)
152
+ if segment.range_end == :max
153
+ "FROM ('#{segment.range_start}') TO (MAXVALUE)"
154
+ elsif segment.hash_partition?
155
+ "WITH (modulus #{segment.range_start[:modulus]}, remainder #{segment.range_start[:remainder]})"
156
+ else
157
+ "FROM ('#{segment.range_start}') TO ('#{segment.range_end}')"
158
+ end
159
+ end
160
+
161
+ def bucket_counts_in_partition(partition_name)
162
+ counts_by_bucket_in_partition(partition_name)
163
+ end
164
+
165
+ private
166
+
167
+ def table_name
168
+ @config[:table_name]
169
+ end
170
+
171
+ def connection
172
+ Connection.connection
173
+ end
174
+
175
+ def today
176
+ PartitionGardener.configuration.today
177
+ end
178
+
179
+ def date_bucket
180
+ DateBucket.normalize(@config.fetch(:bucket, :month))
181
+ end
182
+
183
+ def year_bucket?
184
+ date_bucket == :year
185
+ end
186
+
187
+ def rolling_current_layout?
188
+ @config.fetch(:layout, :sliding_window) == :rolling_current
189
+ end
190
+
191
+ def active_span_for(bucket)
192
+ active_key = DateBucket.active_key(bucket)
193
+ default = case bucket
194
+ when :year then DEFAULT_ACTIVE_YEARS
195
+ when :month then DEFAULT_ACTIVE_MONTHS
196
+ else DateBucket.default_active_span(bucket)
197
+ end
198
+ @config.fetch(active_key, default)
199
+ end
200
+
201
+ def split_row_threshold
202
+ @config.fetch(:split_row_threshold, FUTURE_MONTH_PARTITION_ROW_THRESHOLD)
203
+ end
204
+
205
+ def beginning_of_bucket(bucket)
206
+ DateBucket.beginning_of_bucket(bucket.to_date, date_bucket)
207
+ end
208
+
209
+ def end_of_bucket(bucket)
210
+ DateBucket.end_of_bucket(bucket.to_date, date_bucket)
211
+ end
212
+
213
+ def each_bucket_in_range(range_start, range_end_exclusive)
214
+ buckets = []
215
+ bucket = beginning_of_bucket(range_start)
216
+
217
+ while bucket < range_end_exclusive
218
+ buckets << bucket
219
+ bucket = DateBucket.next_bucket(bucket, date_bucket)
220
+ end
221
+
222
+ buckets
223
+ end
224
+
225
+ def bucket_in_window?(bucket, window)
226
+ return false if window[:start].nil? || window[:end].nil?
227
+
228
+ bucket >= window[:start] && bucket < window[:end]
229
+ end
230
+
231
+ def managed_tail_partition?(partition_name, window:)
232
+ return true if tail_slot_name?(partition_name)
233
+
234
+ bucket = archive_bucket_from_partition_name(partition_name)
235
+ bucket && bucket_in_window?(bucket, window)
236
+ end
237
+
238
+ def segment_kind_for(partition_name, window:)
239
+ return :future if partition_name == future_partition_name(table_name)
240
+ return :filler if tail_slot_name?(partition_name)
241
+
242
+ :hot_bucket
243
+ end
244
+
245
+ def heatmap_source_partitions(window)
246
+ names = tail_slot_names_for_heatmap.select { |name| Connection.partition_attached?(table_name, name) }
247
+ names.concat(hot_bucket_partitions_in_window(window).map(&:first))
248
+ names.uniq
249
+ end
250
+
251
+ def tail_slot_names_for_heatmap
252
+ [
253
+ current_partition_name(table_name),
254
+ open_partition_name(table_name),
255
+ future_partition_name(table_name)
256
+ ] + open_slot_partition_names
257
+ end
258
+
259
+ def open_slot_partition_names
260
+ Connection.attached_partitions(table_name).map(&:name).select do |partition_name|
261
+ partition_name.match?(/^#{Regexp.escape(table_name)}_open_\d+$/)
262
+ end
263
+ end
264
+
265
+ def hot_bucket_partitions_in_window(window)
266
+ Connection.attached_partitions(table_name).filter_map do |partition|
267
+ bucket = archive_bucket_from_partition_name(partition.name)
268
+ next unless bucket
269
+ next unless bucket_in_window?(bucket, window)
270
+
271
+ [partition.name, bucket]
272
+ end
273
+ end
274
+
275
+ def counts_by_bucket_in_partition(partition_name)
276
+ partition_key_column = @config[:partition_key_column]
277
+ trunc_unit = DateBucket.date_trunc_unit(date_bucket)
278
+ bucket_expression = if partition_key_column.include?("::")
279
+ "date_trunc('#{trunc_unit}', #{partition_key_column})::date"
280
+ else
281
+ "date_trunc('#{trunc_unit}', #{connection.quote_column_name(partition_key_column)})::date"
282
+ end
283
+
284
+ sql = <<~SQL
285
+ SELECT #{bucket_expression} AS bucket, COUNT(*)::int AS row_count
286
+ FROM #{Connection.quoted_table(partition_name)}
287
+ GROUP BY 1
288
+ SQL
289
+
290
+ connection.execute(sql).each_with_object({}) do |row, counts|
291
+ counts[Date.parse(row["bucket"].to_s)] = row["row_count"].to_i
292
+ end
293
+ end
294
+
295
+ def default_row_count
296
+ default_name = default_partition_name(table_name)
297
+ return 0 unless Connection.partition_exists?(default_name)
298
+
299
+ Connection.count_rows_in_partition_table(default_name)
300
+ end
301
+ end
302
+ end
303
+ end