partition_gardener 0.3.3 → 0.3.4

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: 2ad4860950aedb375b6703ededf1602006203f1defa7406465f6f87b5d38083b
4
- data.tar.gz: 0af3c3dadfe7c123c5193e9aa1b78b3d8fb5dc1a294a3819c790ad20b18a4d53
3
+ metadata.gz: 4be8b78232ba13211d0baad009dea43f6320b96dd9b82398c8a4e86ed6008b3d
4
+ data.tar.gz: acb6c02d0192c07e3b8c9f959b6ada44e44014f9f5c144906e6649ebb1309e87
5
5
  SHA512:
6
- metadata.gz: 68c44b5b9851c2f167801c26f33b9050f5ba684a0355820ea8738f7309ceebaf35ed3ab2ef3ee8a87830a145c9593b8a994f06ae7b3625901fb7b92963cec2d7
7
- data.tar.gz: 8026d9241cf10f037655ded5999cae214dafe6658b6d3c66a5d336561e87d20b078a63a82321bed1d0b74d0b9b513579196dbe3d3d31235a61c10aedb6d864ab
6
+ metadata.gz: f387a844418342ef9f4ff644faf1ca2431f8259e3f26e9220bab9ce225ae5f1606ceb4651a94cb39f0e7929a7a0b7c2eae779c6aa7d079a8ad4355901f39a63f
7
+ data.tar.gz: a659acdb2ed364d72f44a9ecb0aab7ac925b27b77f8365e9da36582416145c9db9bbe6a1e9da279be036eab0cb4eda3d4a865d5a86a22741564bbcbf87145d32
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.4 (2026-09-02)
6
+
7
+ - Align LIKE and detached children with additive parent columns before `ATTACH PARTITION`, preserving type, collation, default, and `NOT NULL` atomically. Audit warns when a managed child still lags. Set `align_child_columns: false` to fail fast instead.
8
+
5
9
  ## 0.3.3 (2026-08-13)
6
10
 
7
11
  - Roll the sliding-window current partition before attaching the prior month's archive so a month rollover detaches the old current span and attaches that month as its own child
data/SECURITY.md CHANGED
@@ -2,26 +2,24 @@
2
2
 
3
3
  ## Reporting a Vulnerability
4
4
 
5
- Do not open a public issue for security vulnerabilities.
6
-
7
5
  Email security details to: security@kiskolabs.com
8
6
 
9
- Include: description, steps to reproduce, potential impact, and suggested fix (if available).
7
+ Send the report only to that address. Include description, steps to reproduce, potential impact, and a suggested fix when you have one.
10
8
 
11
9
  ### Response Timeline
12
10
 
13
- - We will acknowledge receipt of your report
14
- - We will provide an initial assessment
15
- - We will keep you informed of our progress and resolution timeline
11
+ - We will acknowledge receipt of your report.
12
+ - We will provide an initial assessment.
13
+ - We will keep you informed of our progress and resolution timeline.
16
14
 
17
15
  ### Disclosure Policy
18
16
 
19
- - We will work with you to understand and resolve the issue
20
- - We will credit you for the discovery (unless you prefer to remain anonymous)
21
- - We will publish a security advisory after the vulnerability is patched
22
- - We will coordinate public disclosure with you
17
+ - We will work with you to understand and resolve the issue.
18
+ - We will credit you for the discovery unless you prefer to remain anonymous.
19
+ - We will publish a security advisory after the vulnerability is patched.
20
+ - We will coordinate public disclosure with you.
23
21
 
24
22
  ## Automation Security
25
23
 
26
- - Context Isolation: It is strictly forbidden to include production credentials, API keys, or Personally Identifiable Information (PII) in prompts sent to third-party LLMs or automation services.
27
- - Supply Chain: All automated dependencies must be verified.
24
+ - Keep production credentials, API keys, and personal data out of prompts sent to third-party models or automation services.
25
+ - Verify automated dependencies.
@@ -19,16 +19,16 @@ root logger 1.7.0 Ruby|BSD-2-Clause
19
19
  root minitest 6.0.6 MIT
20
20
  root parallel 2.1.0 MIT
21
21
  root parser 3.3.12.0 MIT
22
- root partition_gardener 0.3.3 MIT
22
+ root partition_gardener 0.3.4 MIT
23
23
  root pg 1.6.3 BSD-2-Clause
24
- root polyrun 2.2.2 MIT
24
+ root polyrun 2.2.3 MIT
25
25
  root prism 1.9.0 MIT
26
26
  root prosopite 2.2.0 Apache-2.0
27
27
  root racc 1.8.1 Ruby|BSD-2-Clause
28
28
  root rack 3.2.6 MIT
29
29
  root rainbow 3.1.1 MIT
30
30
  root rake 13.4.2 MIT
31
- root rbs 4.1.2 BSD-2-Clause|Ruby
31
+ root rbs 4.2.0 BSD-2-Clause|Ruby
32
32
  root regexp_parser 2.12.0 MIT
33
33
  root rspec 3.13.2 MIT
34
34
  root rspec-core 3.13.6 MIT
@@ -36,6 +36,8 @@ Horizon warning threshold: 30 days (`Audit::HORIZON_WARNING_DAYS`). High child c
36
36
 
37
37
  `partition gap: no attached tail partition extends to MAXVALUE` — missing future/open tail. `apply` to attach `_future`.
38
38
 
39
+ `child {name} is missing column {columns} from parent {table}` — a managed LIKE or detached child lacks ordinary columns added on the parent later. `apply` adds those columns atomically then attaches (default). Set `align_child_columns: false` to fail fast. Built-in naming schemes exclude unrelated prefixed tables; custom partition name formatters retain prefix discovery.
40
+
39
41
  Gap objects include `range_start`, `range_end`, and `message`.
40
42
 
41
43
  ## Plan report
@@ -53,6 +53,8 @@ When the gem loads inside Rails, the railtie sets `connection_resolver` and `tod
53
53
 
54
54
  `retention_detach_concurrently` (default: `false`) — detach dropped archive partitions with `CONCURRENTLY` (global default).
55
55
 
56
+ `align_child_columns` (default: `true`) — before `ATTACH PARTITION`, add ordinary parent columns that a LIKE or detached child is missing. Audit warns when a child still lags. Set `false` to fail fast when a child is missing those columns.
57
+
56
58
  ### Advisory locks
57
59
 
58
60
  Each table run acquires `pg_advisory_lock(hashtext('partition_gardener'), hashtext(table_name))` (or the transaction-scoped variant).
@@ -164,6 +166,8 @@ All templates accept shared options passed through to the registry hash:
164
166
 
165
167
  `retention_detach_concurrently` (default: global default) — per-table override for concurrent detach.
166
168
 
169
+ `align_child_columns` (default: global default) — per-table override. Additive only: add missing ordinary columns in one statement (skip identity and generated), preserving type, non-default collation, default, and `NOT NULL`. If existing rows cannot satisfy `NOT NULL`, PostgreSQL rejects the complete alignment and leaves the child unchanged. Existing types, extra child columns, and other constraints stay as they are.
170
+
167
171
  `maintenance_backend` (default: `:gardener`) — `:gardener`, `:pg_partman`, or `:hybrid_layout_only`.
168
172
 
169
173
  `incremental_rebalance` (default: global default) — per-table override.
@@ -92,6 +92,10 @@
92
92
  "retention_detach_concurrently": {
93
93
  "type": "boolean"
94
94
  },
95
+ "align_child_columns": {
96
+ "type": "boolean",
97
+ "description": "When true (default), atomically add missing ordinary parent columns with their collation, default, and NOT NULL contract on a LIKE or detached child before ATTACH PARTITION"
98
+ },
95
99
  "hash_modulus": {
96
100
  "type": "integer",
97
101
  "minimum": 2,
@@ -61,6 +61,10 @@ module PartitionGardener
61
61
  warnings << "partition gap: #{gap.message}"
62
62
  end
63
63
 
64
+ warning_options = {}
65
+ warning_options[:candidate_table] = method(:managed_child_table?) if @config
66
+ warnings.concat(ChildColumnAlign.lagging_child_warnings(@table_name, **warning_options))
67
+
64
68
  AuditResult.new(
65
69
  table_name: @table_name,
66
70
  partitioned: true,
@@ -74,6 +78,18 @@ module PartitionGardener
74
78
 
75
79
  private
76
80
 
81
+ def managed_child_table?(child_table)
82
+ return true if @config[:partition_name_format_is_custom] || @config[:archive_partition_name_format]
83
+ return true if child_table == Naming.rebalance_staging_partition_name(@table_name)
84
+
85
+ strategy = (@strategy ||= Strategy.for(@config))
86
+ return true if strategy.respond_to?(:tail_slot_name?) && strategy.tail_slot_name?(child_table)
87
+
88
+ !strategy.archive_bucket_from_partition_name(child_table).nil?
89
+ rescue Date::Error
90
+ false
91
+ end
92
+
77
93
  def compute_horizon_days(partitions)
78
94
  today = PartitionGardener.configuration.today
79
95
  finite_ends = partitions.filter_map do |partition|
@@ -0,0 +1,165 @@
1
+ module PartitionGardener
2
+ class ChildColumnAlign
3
+ Column = Data.define(
4
+ :name,
5
+ :type,
6
+ :not_null,
7
+ :default_expression,
8
+ :collation_schema,
9
+ :collation_name
10
+ )
11
+
12
+ def self.lagging_child_warnings(parent_table, connection: Connection.connection, candidate_table: nil)
13
+ new(connection: connection).lagging_child_warnings(parent_table, candidate_table: candidate_table)
14
+ end
15
+
16
+ def initialize(connection: Connection.connection)
17
+ @connection = connection
18
+ end
19
+
20
+ def align!(parent_table, child_table)
21
+ missing = missing_ordinary_columns(parent_table, child_table)
22
+ add_columns!(child_table, missing) if missing.any?
23
+ end
24
+
25
+ def lagging_child_warnings(parent_table, candidate_table: nil)
26
+ child_tables = unattached_child_tables(parent_table)
27
+ child_tables.select!(&candidate_table) if candidate_table
28
+ columns_by_table = ordinary_columns_by_table([parent_table, *child_tables])
29
+ parent_columns = columns_by_table.fetch(parent_table, [])
30
+
31
+ child_tables.filter_map do |child_table|
32
+ missing = missing_columns(parent_columns, columns_by_table.fetch(child_table, []))
33
+ next if missing.empty?
34
+
35
+ missing_columns_warning(child_table, parent_table, missing)
36
+ end
37
+ end
38
+
39
+ def missing_ordinary_columns(parent_table, child_table)
40
+ columns_by_table = ordinary_columns_by_table([parent_table, child_table])
41
+ missing_columns(
42
+ columns_by_table.fetch(parent_table, []),
43
+ columns_by_table.fetch(child_table, [])
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ def add_columns!(child_table, columns)
50
+ actions = columns.map { |column| add_column_action(column) }.join(",\n ")
51
+ @connection.execute(<<~SQL)
52
+ ALTER TABLE #{quoted_table(child_table)}
53
+ #{actions}
54
+ SQL
55
+ end
56
+
57
+ def add_column_action(column)
58
+ clauses = ["ADD COLUMN #{quoted_column(column.name)} #{column.type}"]
59
+ if column.collation_name
60
+ clauses << "COLLATE #{quoted_column(column.collation_schema)}.#{quoted_column(column.collation_name)}"
61
+ end
62
+ clauses << "DEFAULT #{column.default_expression}" if usable_default?(column.default_expression)
63
+ clauses << "NOT NULL" if column.not_null
64
+ clauses.join(" ")
65
+ end
66
+
67
+ def ordinary_columns_by_table(table_names)
68
+ names = table_names.uniq
69
+ quoted_names = names.map { |table_name| @connection.quote(table_name) }.join(", ")
70
+ sql = <<~SQL
71
+ SELECT
72
+ table_class.relname AS table_name,
73
+ attribute.attname AS name,
74
+ pg_catalog.format_type(attribute.atttypid, attribute.atttypmod) AS type,
75
+ attribute.attnotnull AS not_null,
76
+ pg_get_expr(column_default.adbin, column_default.adrelid) AS default_expression,
77
+ attribute.attidentity AS identity,
78
+ attribute.attgenerated AS generated,
79
+ collation_namespace.nspname AS collation_schema,
80
+ column_collation.collname AS collation_name
81
+ FROM pg_attribute attribute
82
+ LEFT JOIN pg_attrdef column_default
83
+ ON column_default.adrelid = attribute.attrelid
84
+ AND column_default.adnum = attribute.attnum
85
+ JOIN pg_class table_class ON table_class.oid = attribute.attrelid
86
+ JOIN pg_namespace table_namespace ON table_namespace.oid = table_class.relnamespace
87
+ JOIN pg_type attribute_type ON attribute_type.oid = attribute.atttypid
88
+ LEFT JOIN pg_collation column_collation
89
+ ON column_collation.oid = attribute.attcollation
90
+ AND attribute.attcollation <> attribute_type.typcollation
91
+ LEFT JOIN pg_namespace collation_namespace
92
+ ON collation_namespace.oid = column_collation.collnamespace
93
+ WHERE table_namespace.nspname = #{@connection.quote(schema_name)}
94
+ AND table_class.relname IN (#{quoted_names})
95
+ AND attribute.attnum > 0
96
+ AND NOT attribute.attisdropped
97
+ ORDER BY table_class.relname, attribute.attnum
98
+ SQL
99
+ @connection.execute(sql).each_with_object({}) do |row, columns_by_table|
100
+ next if Blank.present?(row["identity"]) || Blank.present?(row["generated"])
101
+
102
+ column = Column.new(
103
+ name: row["name"],
104
+ type: row["type"],
105
+ not_null: flag?(row["not_null"]),
106
+ default_expression: row["default_expression"],
107
+ collation_schema: row["collation_schema"],
108
+ collation_name: row["collation_name"]
109
+ )
110
+ (columns_by_table[row["table_name"]] ||= []) << column
111
+ end
112
+ end
113
+
114
+ def missing_columns(parent_columns, child_columns)
115
+ child_names = child_columns.to_h { |column| [column.name, true] }
116
+ parent_columns.reject { |column| child_names.key?(column.name) }
117
+ end
118
+
119
+ def unattached_child_tables(parent_table)
120
+ sql = <<~SQL
121
+ SELECT child.relname
122
+ FROM pg_class child
123
+ JOIN pg_namespace child_namespace ON child_namespace.oid = child.relnamespace
124
+ WHERE child_namespace.nspname = #{@connection.quote(schema_name)}
125
+ AND child.relkind = 'r'
126
+ AND starts_with(child.relname, #{@connection.quote("#{parent_table}_")})
127
+ AND NOT EXISTS (
128
+ SELECT 1
129
+ FROM pg_inherits inheritance
130
+ WHERE inheritance.inhrelid = child.oid
131
+ )
132
+ ORDER BY child.relname
133
+ SQL
134
+ @connection.execute(sql).map { |row| row["relname"] }
135
+ end
136
+
137
+ def missing_columns_warning(child_table, parent_table, missing)
138
+ names = missing.map(&:name).join(", ")
139
+ noun = (missing.size == 1) ? "column" : "columns"
140
+ "child #{child_table} is missing #{noun} #{names} from parent #{parent_table}"
141
+ end
142
+
143
+ def usable_default?(expression)
144
+ return false if Blank.blank?(expression)
145
+
146
+ !expression.strip.match?(/\ANULL(::[\w.]+)?\z/i)
147
+ end
148
+
149
+ def flag?(value)
150
+ value == true || value.to_s == "t" || value.to_s == "true"
151
+ end
152
+
153
+ def quoted_table(name)
154
+ @connection.quote_table_name(name)
155
+ end
156
+
157
+ def quoted_column(name)
158
+ @connection.quote_column_name(name)
159
+ end
160
+
161
+ def schema_name
162
+ PartitionGardener.configuration.schema_name
163
+ end
164
+ end
165
+ end
@@ -22,6 +22,7 @@ module PartitionGardener
22
22
  retention_apply
23
23
  retention_keep_table
24
24
  retention_detach_concurrently
25
+ align_child_columns
25
26
  hash_modulus
26
27
  maintenance_backend
27
28
  incremental_rebalance
@@ -173,6 +174,7 @@ module PartitionGardener
173
174
  validate_registry_conflict_key!(document)
174
175
  validate_registry_optional_bucket!(document)
175
176
  validate_registry_optional_maintenance_backend!(document)
177
+ validate_registry_optional_boolean!(document, "align_child_columns")
176
178
  document
177
179
  end
178
180
 
@@ -233,6 +235,13 @@ module PartitionGardener
233
235
  raise ArgumentError, "maintenance_backend must be gardener, pg_partman, or hybrid_layout_only"
234
236
  end
235
237
 
238
+ def validate_registry_optional_boolean!(document, key)
239
+ return unless document.key?(key)
240
+ return if document.fetch(key) == true || document.fetch(key) == false
241
+
242
+ raise ArgumentError, "#{key} must be true or false"
243
+ end
244
+
236
245
  def register_validated_document!(document)
237
246
  Registry.register(from_hash(validate_registry_document!(document)))
238
247
  end
@@ -11,6 +11,7 @@ module PartitionGardener
11
11
  :incremental_rebalance,
12
12
  :run_record_enabled,
13
13
  :retention_detach_concurrently,
14
+ :align_child_columns,
14
15
  :strict_maintenance_backend_validation,
15
16
  :current_run_metrics
16
17
 
@@ -29,6 +30,7 @@ module PartitionGardener
29
30
  @run_record_enabled = true
30
31
  @run_record_store = nil
31
32
  @retention_detach_concurrently = false
33
+ @align_child_columns = true
32
34
  @strict_maintenance_backend_validation = false
33
35
  @current_run_metrics = nil
34
36
  end
@@ -5,16 +5,26 @@ module PartitionGardener
5
5
  def self.for_config(config, connection: Connection.connection)
6
6
  new(
7
7
  connection: connection,
8
- batch_size: config.fetch(:move_batch_size, MOVE_BATCH_SIZE)
8
+ batch_size: config.fetch(:move_batch_size, MOVE_BATCH_SIZE),
9
+ align_child_columns: config.fetch(
10
+ :align_child_columns,
11
+ PartitionGardener.configuration.align_child_columns
12
+ )
9
13
  )
10
14
  end
11
15
 
12
- def initialize(connection: Connection.connection, batch_size: MOVE_BATCH_SIZE)
16
+ def initialize(
17
+ connection: Connection.connection,
18
+ batch_size: MOVE_BATCH_SIZE,
19
+ align_child_columns: PartitionGardener.configuration.align_child_columns
20
+ )
13
21
  @connection = connection
14
22
  @batch_size = batch_size
23
+ @align_child_columns = align_child_columns
15
24
  end
16
25
 
17
26
  def attach_partition(table_name, partition_name, for_values_clause)
27
+ align_child_columns!(table_name, partition_name)
18
28
  sql = <<~SQL
19
29
  ALTER TABLE #{quoted_table(table_name)} ATTACH PARTITION #{quoted_table(partition_name)}
20
30
  FOR VALUES #{for_values_clause}
@@ -23,6 +33,7 @@ module PartitionGardener
23
33
  end
24
34
 
25
35
  def attach_default_partition(table_name, partition_name)
36
+ align_child_columns!(table_name, partition_name)
26
37
  sql = <<~SQL
27
38
  ALTER TABLE #{quoted_table(table_name)} ATTACH PARTITION #{quoted_table(partition_name)} DEFAULT
28
39
  SQL
@@ -59,6 +70,7 @@ module PartitionGardener
59
70
  )
60
71
  SQL
61
72
  @connection.execute(sql)
73
+ align_child_columns!(table_name, partition_name)
62
74
 
63
75
  conflict_columns = conflict_key.map { |column| @connection.quote_column_name(column) }.join(", ")
64
76
  sql = <<~SQL
@@ -141,6 +153,12 @@ module PartitionGardener
141
153
 
142
154
  private
143
155
 
156
+ def align_child_columns!(parent_table, child_table)
157
+ return unless @align_child_columns
158
+
159
+ ChildColumnAlign.new(connection: @connection).align!(parent_table, child_table)
160
+ end
161
+
144
162
  def quoted_table(name)
145
163
  @connection.quote_table_name(name)
146
164
  end
@@ -4,6 +4,9 @@ module PartitionGardener
4
4
 
5
5
  def normalize(config)
6
6
  config = config.dup
7
+ unless config.key?(:partition_name_format_is_custom)
8
+ config[:partition_name_format_is_custom] = config.key?(:partition_name_format)
9
+ end
7
10
  config[:layout] ||= :sliding_window
8
11
  config[:bucket] ||= :month if date_layout?(config[:layout])
9
12
  config
@@ -106,6 +109,7 @@ module PartitionGardener
106
109
  conflict_key: conflict_key,
107
110
  premake_months: premake_months,
108
111
  maintenance_backend: options.fetch(:maintenance_backend, :gardener),
112
+ partition_name_format_is_custom: options.key?(:partition_name_format),
109
113
  partition_name_format: options.fetch(:partition_name_format) {
110
114
  ->(identifier) { DateBucket.partition_name(table_name, identifier, :month) }
111
115
  },
@@ -128,6 +132,7 @@ module PartitionGardener
128
132
  partition_key_column: partition_key_column,
129
133
  conflict_key: conflict_key,
130
134
  active_years: active_years,
135
+ partition_name_format_is_custom: options.key?(:partition_name_format),
131
136
  partition_name_format: options.fetch(:partition_name_format) {
132
137
  ->(identifier) { DateBucket.partition_name(table_name, identifier, :year) }
133
138
  },
@@ -301,6 +306,7 @@ module PartitionGardener
301
306
  :partition_key_column => partition_key_column,
302
307
  :conflict_key => conflict_key,
303
308
  active_key => active_value,
309
+ :partition_name_format_is_custom => options.key?(:partition_name_format),
304
310
  :partition_name_format => options.fetch(:partition_name_format) {
305
311
  ->(identifier) { DateBucket.partition_name(table_name, identifier, bucket) }
306
312
  },
@@ -1,3 +1,3 @@
1
1
  module PartitionGardener
2
- VERSION = "0.3.3"
2
+ VERSION = "0.3.4"
3
3
  end
@@ -14,6 +14,7 @@ require_relative "partition_gardener/configuration"
14
14
  require_relative "partition_gardener/naming"
15
15
  require_relative "partition_gardener/plan"
16
16
  require_relative "partition_gardener/connection"
17
+ require_relative "partition_gardener/child_column_align"
17
18
  require_relative "partition_gardener/executor"
18
19
  require_relative "partition_gardener/default_partition"
19
20
  require_relative "partition_gardener/strategy/requires_default_partition"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: partition_gardener
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -303,6 +303,7 @@ files:
303
303
  - lib/partition_gardener/archive_retention.rb
304
304
  - lib/partition_gardener/audit.rb
305
305
  - lib/partition_gardener/blank.rb
306
+ - lib/partition_gardener/child_column_align.rb
306
307
  - lib/partition_gardener/cli.rb
307
308
  - lib/partition_gardener/config_document.rb
308
309
  - lib/partition_gardener/configuration.rb