partition_gardener 0.3.3 → 0.3.5

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: abde0192951f5cc668d53ca847f119c3d9dcf5c994404504119cfb1d54c6be17
4
+ data.tar.gz: 47eab4f81f45acf7a59730e2a04ac48769e655557da78c0db12be355a85ce6b2
5
5
  SHA512:
6
- metadata.gz: 68c44b5b9851c2f167801c26f33b9050f5ba684a0355820ea8738f7309ceebaf35ed3ab2ef3ee8a87830a145c9593b8a994f06ae7b3625901fb7b92963cec2d7
7
- data.tar.gz: 8026d9241cf10f037655ded5999cae214dafe6658b6d3c66a5d336561e87d20b078a63a82321bed1d0b74d0b9b513579196dbe3d3d31235a61c10aedb6d864ab
6
+ metadata.gz: 51677ad1c0b6d9513a86e574e9254776a7b9cecbd83f230a38b61d1a3f04be5313987901658aa9aebbf78e47a0f5ddd9b9573c6b3396c599ac6c60fdc932ab96
7
+ data.tar.gz: f1f4271c57f3a1ed9eaf2ca2f47d351cb50f729377f21bfac7657ce9d5342067b8dbc4c005d3db926d9cbf25186fbf0f431d287daf8139111758c2d647e7ac8d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.5 (2026-09-09)
6
+
7
+ - Keep attached monthly children that already cover the start of the sliding-window active range, and fill the remainder as `_open` rather than overlapping `_current`
8
+ - Start `_future` after premade months that occupy keys at or after the active window end, and skip `_future` when an occupying child already ends at MAXVALUE
9
+ - Add the RFC process (RFC 0001) and a Proposed occupancy-first contract for `_current`, `_open`, and `_future` (RFC 0002)
10
+ - Specify shipped registry, plan, CLI, layout, rebalance, lock, hot-switch, and column-align contracts as Stable RFCs 0003 through 0015
11
+
12
+ ## 0.3.4 (2026-09-02)
13
+
14
+ - 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.
15
+
5
16
  ## 0.3.3 (2026-08-13)
6
17
 
7
18
  - 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/README.md CHANGED
@@ -41,9 +41,10 @@ Complements migration gems (e.g. [pg_party](https://github.com/rkrage/pg_party))
41
41
  - [docs/naming.md](docs/naming.md) — child partition naming catalog
42
42
  - [docs/host_testing.md](docs/host_testing.md) — CI and staging for host apps
43
43
 
44
- ### Schemas
44
+ ### Schemas and RFCs
45
45
 
46
46
  - [docs/schemas/](docs/schemas/) — JSON schemas for registry and plan reports
47
+ - [rfcs/](rfcs/) — design review; RFC 0002 Proposed tail slots; RFC 0003–0015 Stable shipped contracts
47
48
 
48
49
  ## Quick start
49
50
 
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.
@@ -17,18 +17,18 @@ root language_server-protocol 3.17.0.6 MIT
17
17
  root lint_roller 1.1.0 MIT
18
18
  root logger 1.7.0 Ruby|BSD-2-Clause
19
19
  root minitest 6.0.6 MIT
20
- root parallel 2.1.0 MIT
20
+ root parallel 2.2.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.5 MIT
23
23
  root pg 1.6.3 BSD-2-Clause
24
- root polyrun 2.2.2 MIT
24
+ root polyrun 2.2.4 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
- root rack 3.2.6 MIT
28
+ root rack 3.2.7 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
@@ -120,6 +122,7 @@ Disable globally or per table if you do not want checkpoint storage ([configurat
120
122
 
121
123
  ## Related
122
124
 
125
+ - [RFC 0005](../rfcs/0005-plan-audit-reports.md) — plan, audit, and RunSummary contract
123
126
  - [operations.md](operations.md) — remediation workflows
124
127
  - [monitoring.md](monitoring.md) — metrics from these fields
125
128
  - [naming.md](naming.md) — segment names in output
data/docs/cli.md CHANGED
@@ -58,13 +58,14 @@ bundle exec partition_gardener --rails apply --confirm audits
58
58
  Apply all registered gardener-owned tables:
59
59
 
60
60
  ```bash
61
- bundle exec partition_gardener --rails apply --all
61
+ bundle exec partition_gardener --rails apply --confirm --all
62
62
  ```
63
63
 
64
64
  Omitting a table name without `--all` also runs the full registry.
65
65
 
66
66
  ## Related
67
67
 
68
+ - [RFC 0008](../rfcs/0008-cli.md) — verbs, confirm gate, table selection
68
69
  - [configuration.md](configuration.md) — registry format and global config
69
70
  - [background_job.md](background_job.md) — when to use a job instead of CLI apply
70
71
  - [audit_reference.md](audit_reference.md) — audit and plan output reference
@@ -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.
@@ -253,6 +257,8 @@ Registry entries are operator-controlled configuration, not end-user input.
253
257
 
254
258
  ## Related
255
259
 
260
+ - [RFC 0004](../rfcs/0004-registry-json.md) — portable registry file
261
+ - [RFC 0009](../rfcs/0009-maintenance-backends.md) — gardener, pg_partman, hybrid
256
262
  - [background_job.md](background_job.md) — schedule `run!` from a host job
257
263
  - [cli.md](cli.md) — plan, audit, apply from the shell
258
264
  - [decision_flow.md](decision_flow.md) — when and how to partition
data/docs/naming.md CHANGED
@@ -8,13 +8,13 @@ Fixed suffixes on the parent table name:
8
8
 
9
9
  `{table}_default` — catch-all; must trend empty.
10
10
 
11
- `{table}_current` — start of active window.
11
+ `{table}_current` — filler that starts at the active window origin. The span is the uncovered remainder of that window, not this calendar month or sequence id.
12
12
 
13
- `{table}_open` — gap filler in current zone.
13
+ `{table}_open` — gap filler in the current zone when the origin is already a named child or a heat split. Extra fillers after that are `{table}_open_N`.
14
14
 
15
15
  `{table}_open_N` — extra fillers after heat splits.
16
16
 
17
- `{table}_future` — open-ended tail to MAXVALUE.
17
+ `{table}_future` — open-ended tail to MAXVALUE. It starts at the first instant at or after the window end that no occupying child covers.
18
18
 
19
19
  `{table}_rebalance_staging` — temporary during tail rebalance.
20
20
 
@@ -66,6 +66,8 @@ Remediation: prefer `plan` + `apply` over manual attach. If manual children are
66
66
 
67
67
  ## Related
68
68
 
69
+ - [RFC 0002](../rfcs/0002-tail-slots.md) — names and occupancy-first placement
70
+ - [RFC 0006](../rfcs/0006-archive-naming.md) — date-bucket suffixes and parse-back
69
71
  - [operations.md](operations.md) — gap remediation
70
72
  - [cutover.md](cutover.md) — shadow `p_` prefix
71
73
  - [audit_reference.md](audit_reference.md) — gap messages
data/docs/retention.md CHANGED
@@ -60,6 +60,7 @@ When `maintenance_backend: :pg_partman`, gardener skips the table; partman owns
60
60
 
61
61
  ## Related
62
62
 
63
+ - [RFC 0007](../rfcs/0007-retention.md) — cutoff, preview, skip list
63
64
  - [decision_flow.md](decision_flow.md) — retention in layout choice
64
65
  - [operations.md](operations.md) — incident response
65
66
  - [monitoring.md](monitoring.md) — alerts
@@ -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
@@ -101,7 +101,7 @@ module PartitionGardener
101
101
  match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})$/)
102
102
  return Date.new(match[1].to_i, 1, 1) if match
103
103
  else
104
- match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})_(\d{2})$/)
104
+ match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})_(\d{2})(?:_01)?$/)
105
105
  return Date.new(match[1].to_i, match[2].to_i, 1) if match
106
106
  end
107
107
 
@@ -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
@@ -8,7 +8,7 @@ module PartitionGardener
8
8
  DateCalendar.add_years(active_start, active_years)
9
9
  end
10
10
 
11
- def build_segments(config:, active_start:, active_end:, hot_years:)
11
+ def build_segments(config:, active_start:, active_end:, hot_years:, occupied_segments: [])
12
12
  strategy = Strategy::DateRange.new(config.merge(bucket: :year))
13
13
  buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
14
14
 
@@ -19,7 +19,8 @@ module PartitionGardener
19
19
  active_start: active_start,
20
20
  active_end: active_end,
21
21
  hot_bucket_name: config[:partition_name_format],
22
- bucket_end: ->(bucket) { DateCalendar.beginning_of_year(DateCalendar.next_year(bucket)) }
22
+ bucket_end: ->(bucket) { DateCalendar.beginning_of_year(DateCalendar.next_year(bucket)) },
23
+ occupied_segments: occupied_segments
23
24
  )
24
25
  end
25
26
  end
@@ -0,0 +1,86 @@
1
+ module PartitionGardener
2
+ module Layout
3
+ module OccupiedWindow
4
+ module_function
5
+
6
+ def plan_segments(config:, window:, hot_buckets:, year_bucket:, tail_slot:)
7
+ occupied_segments = segments(table_name: config[:table_name], window: window, tail_slot: tail_slot)
8
+ layout_kwargs = {
9
+ config: config,
10
+ active_start: window[:start],
11
+ active_end: window[:end],
12
+ occupied_segments: occupied_segments
13
+ }
14
+
15
+ if year_bucket
16
+ CalendarYear.build_segments(**layout_kwargs, hot_years: hot_buckets)
17
+ else
18
+ SlidingWindow.build_segments(**layout_kwargs, hot_months: hot_buckets)
19
+ end
20
+ end
21
+
22
+ def segments(table_name:, window:, tail_slot:)
23
+ Connection.attached_partitions(table_name).filter_map do |partition|
24
+ next if partition.default
25
+ next if tail_slot.call(partition.name)
26
+ next unless overlap?(partition, window)
27
+
28
+ Plan::Segment.new(
29
+ name: partition.name,
30
+ range_start: partition.range_start,
31
+ range_end: partition.range_end,
32
+ kind: :hot_bucket
33
+ )
34
+ end.sort_by(&:range_start)
35
+ end
36
+
37
+ def overlap?(partition, window)
38
+ return false unless partition.range_start.is_a?(Date)
39
+ return false if partition.range_end.nil?
40
+ return true if partition.range_end == :max
41
+ return true if partition.range_end > window[:end]
42
+
43
+ partition.range_start < window[:end] && partition.range_end > window[:start]
44
+ end
45
+
46
+ def covering(occupied_segments, bucket)
47
+ occupied_segments.find do |segment|
48
+ next false if segment.range_start > bucket
49
+
50
+ segment.range_end == :max || segment.range_end > bucket
51
+ end
52
+ end
53
+
54
+ def next_bucket_index(buckets, bound, from:)
55
+ return buckets.length if bound == :max
56
+
57
+ index = from
58
+ index += 1 while index < buckets.length && buckets[index] < bound
59
+ index
60
+ end
61
+
62
+ def finish_with_high_end(segments, occupied_segments:, table_name:, active_end:)
63
+ leftover = occupied_segments.reject { |occupant| segments.any? { |segment| segment.name == occupant.name } }
64
+ leftover.each { |occupant| segments << occupant }
65
+
66
+ return segments if occupied_segments.any? { |segment| segment.range_end == :max }
67
+
68
+ segments << Plan::Segment.new(
69
+ name: Naming.future_partition_name(table_name),
70
+ range_start: future_start(occupied_segments, active_end),
71
+ range_end: :max,
72
+ kind: :future
73
+ )
74
+ segments
75
+ end
76
+
77
+ def future_start(occupied_segments, active_end)
78
+ later_ends = occupied_segments.filter_map do |segment|
79
+ segment.range_end if segment.range_end.is_a?(Date) && segment.range_end > active_end
80
+ end
81
+
82
+ [active_end, *later_ends].max
83
+ end
84
+ end
85
+ end
86
+ end
@@ -7,7 +7,7 @@ module PartitionGardener
7
7
  DateBucket.add_buckets(active_start, span, bucket)
8
8
  end
9
9
 
10
- def build_segments(config:, active_start:, active_end:, hot_months:)
10
+ def build_segments(config:, active_start:, active_end:, hot_months:, occupied_segments: [])
11
11
  strategy = Strategy::DateRange.new(config)
12
12
  buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
13
13
  bucket = config.fetch(:bucket, :month)
@@ -19,7 +19,8 @@ module PartitionGardener
19
19
  active_start: active_start,
20
20
  active_end: active_end,
21
21
  hot_bucket_name: config[:partition_name_format],
22
- bucket_end: ->(bucket_start) { DateBucket.end_of_bucket(bucket_start, bucket) }
22
+ bucket_end: ->(bucket_start) { DateBucket.end_of_bucket(bucket_start, bucket) },
23
+ occupied_segments: occupied_segments
23
24
  )
24
25
  end
25
26
  end
@@ -3,7 +3,7 @@ module PartitionGardener
3
3
  module ZoneSegments
4
4
  module_function
5
5
 
6
- def build_filler_and_hot_segments(table_name:, buckets:, hot_buckets:, active_start:, active_end:, hot_bucket_name:, bucket_end:)
6
+ def build_filler_and_hot_segments(table_name:, buckets:, hot_buckets:, active_start:, active_end:, hot_bucket_name:, bucket_end:, occupied_segments: [])
7
7
  hot_bucket_set = hot_buckets.to_set
8
8
  segments = []
9
9
  middle_filler_index = 0
@@ -11,6 +11,13 @@ module PartitionGardener
11
11
 
12
12
  while index < buckets.length
13
13
  bucket = buckets[index]
14
+ occupant = OccupiedWindow.covering(occupied_segments, bucket)
15
+
16
+ if occupant
17
+ segments << occupant unless segments.any? { |segment| segment.name == occupant.name }
18
+ index = OccupiedWindow.next_bucket_index(buckets, occupant.range_end, from: index)
19
+ next
20
+ end
14
21
 
15
22
  if hot_bucket_set.include?(bucket)
16
23
  segments << Plan::Segment.new(
@@ -24,7 +31,7 @@ module PartitionGardener
24
31
  end
25
32
 
26
33
  run_start = bucket
27
- while index < buckets.length && !hot_bucket_set.include?(buckets[index])
34
+ while index < buckets.length && !hot_bucket_set.include?(buckets[index]) && OccupiedWindow.covering(occupied_segments, buckets[index]).nil?
28
35
  index += 1
29
36
  end
30
37
  run_end = (index < buckets.length) ? buckets[index] : active_end
@@ -48,14 +55,12 @@ module PartitionGardener
48
55
  )
49
56
  end
50
57
 
51
- segments << Plan::Segment.new(
52
- name: Naming.future_partition_name(table_name),
53
- range_start: active_end,
54
- range_end: :max,
55
- kind: :future
58
+ OccupiedWindow.finish_with_high_end(
59
+ segments,
60
+ occupied_segments: occupied_segments,
61
+ table_name: table_name,
62
+ active_end: active_end
56
63
  )
57
-
58
- segments
59
64
  end
60
65
  end
61
66
  end
@@ -25,22 +25,13 @@ module PartitionGardener
25
25
  window = active_window
26
26
  heatmap = collect_heatmap(window)
27
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
28
+ segments = Layout::OccupiedWindow.plan_segments(
29
+ config: @config,
30
+ window: window,
31
+ hot_buckets: hot_buckets,
32
+ year_bucket: year_bucket?,
33
+ tail_slot: method(:tail_slot_name?)
34
+ )
44
35
 
45
36
  Plan::Result.new(segments: segments, hot_buckets: hot_buckets)
46
37
  end
@@ -231,6 +222,9 @@ module PartitionGardener
231
222
  def managed_tail_partition?(partition_name, window:)
232
223
  return true if tail_slot_name?(partition_name)
233
224
 
225
+ partition = Connection.attached_partitions(table_name).find { |item| item.name == partition_name }
226
+ return true if partition && Layout::OccupiedWindow.overlap?(partition, window)
227
+
234
228
  bucket = archive_bucket_from_partition_name(partition_name)
235
229
  bucket && bucket_in_window?(bucket, window)
236
230
  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.5"
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"
@@ -25,6 +26,7 @@ require_relative "partition_gardener/strategy/list_split"
25
26
  require_relative "partition_gardener/strategy/composite"
26
27
  require_relative "partition_gardener/strategy"
27
28
  require_relative "partition_gardener/layout/three_area"
29
+ require_relative "partition_gardener/layout/occupied_window"
28
30
  require_relative "partition_gardener/layout/zone_segments"
29
31
  require_relative "partition_gardener/layout/sliding_window"
30
32
  require_relative "partition_gardener/layout/calendar_year"
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.5
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
@@ -316,6 +317,7 @@ files:
316
317
  - lib/partition_gardener/hash_routing.rb
317
318
  - lib/partition_gardener/layout/calendar_year.rb
318
319
  - lib/partition_gardener/layout/integer_window.rb
320
+ - lib/partition_gardener/layout/occupied_window.rb
319
321
  - lib/partition_gardener/layout/sliding_window.rb
320
322
  - lib/partition_gardener/layout/three_area.rb
321
323
  - lib/partition_gardener/layout/zone_segments.rb