code0-zero_track 0.0.7 → 0.0.8
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 +4 -4
- data/LICENSE +1 -1
- data/README.md +128 -1
- data/lib/code0/zero_track/database/migration.rb +1 -0
- data/lib/code0/zero_track/database/migration_helpers/table_partitioning.rb +106 -0
- data/lib/code0/zero_track/database/partitioning/partition_manager.rb +148 -0
- data/lib/code0/zero_track/database/partitioning/partitioned_table.rb +33 -0
- data/lib/code0/zero_track/database/partitioning/postgres_detached_partition.rb +26 -0
- data/lib/code0/zero_track/database/partitioning/postgres_partition.rb +24 -0
- data/lib/code0/zero_track/database/partitioning/postgres_partitioned_table.rb +30 -0
- data/lib/code0/zero_track/database/partitioning/strategy/base.rb +72 -0
- data/lib/code0/zero_track/database/partitioning/strategy/daily.rb +33 -0
- data/lib/code0/zero_track/database/partitioning/strategy/monthly.rb +33 -0
- data/lib/code0/zero_track/database/partitioning/strategy/time.rb +78 -0
- data/lib/code0/zero_track/database/partitioning/time_partition.rb +94 -0
- data/lib/code0/zero_track/database/schema_cleaner.rb +29 -0
- data/lib/code0/zero_track/railtie.rb +5 -0
- data/lib/code0/zero_track/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6150c745af141848ee0848ec9beda4db5c1809ece022f90b9006cf69df88f10
|
|
4
|
+
data.tar.gz: 73128d1d5ab33dec7887b195831c8a1b591eb46999c5cb4e20eb79387d5d138e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 129416f538b904432c213a42a22d95244220fb9c35548b089d9a9ef4836b11b1b3fd0a93c910ab748dfd51d024d56d00ac525e7fd22855330916462b848a3824
|
|
7
|
+
data.tar.gz: 78bba0f4d37e6a08b2d5b02a46478060e7ea944ee8a40c407a4f63c819c450ae41e86b33df9b47a28472ebb202dad28a49c2c95fa221de041d672de1164e8a6d
|
data/LICENSE
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copyright 2023-2026 Code0 UG (haftungsbeschränkt)
|
|
1
|
+
Copyright 2023-2026 Code0 UG (haftungsbeschränkt)
|
|
2
2
|
|
|
3
3
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
4
|
|
data/README.md
CHANGED
|
@@ -65,4 +65,131 @@ can be filled with the correct entries when the schema is loaded from the schema
|
|
|
65
65
|
|
|
66
66
|
This approach is prone to git conflicts, so you can switch to a file based persistence
|
|
67
67
|
with `config.zero_track.active_record.schema_migrations = true`. Instead of an `INSERT INTO` in
|
|
68
|
-
the `db/structure.sql`, this mode creates files in the `db/schema_migrations` directory.
|
|
68
|
+
the `db/structure.sql`, this mode creates files in the `db/schema_migrations` directory.
|
|
69
|
+
|
|
70
|
+
### Table Partitioning
|
|
71
|
+
|
|
72
|
+
PostgreSQL supports declarative table partitioning. The partition manager automates the
|
|
73
|
+
management of partitions without manual operations or extensions on the PostgreSQL server.
|
|
74
|
+
|
|
75
|
+
#### Configuration
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
config.zero_track.db_partitioning.dynamic_partition_schema = 'partitions_dynamic' # default
|
|
79
|
+
config.zero_track.db_partitioning.base_ar_class = 'ActiveRecord::Base' # default
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `dynamic_partition_schema`: The PostgreSQL schema where dynamic partitions are stored.
|
|
83
|
+
- `base_ar_class`: The ActiveRecord base class used for the internal partitioning models.
|
|
84
|
+
|
|
85
|
+
#### Migration Helpers
|
|
86
|
+
|
|
87
|
+
Include the migration helpers by inheriting from `Code0::ZeroTrack::Database::Migration[1.0]` (or the
|
|
88
|
+
appropriate version). The following methods become available:
|
|
89
|
+
|
|
90
|
+
`create_partition_by_date_table(table_name, partition_column:, **options, &block)` creates a table
|
|
91
|
+
partitioned by range on the given column. It automatically sets up a composite primary key
|
|
92
|
+
of `(id, partition_column)`.
|
|
93
|
+
|
|
94
|
+
`create_dynamic_partition_schema` / `drop_dynamic_partition_schema` creates or drops the schema
|
|
95
|
+
used for storing dynamic partitions.
|
|
96
|
+
|
|
97
|
+
`create_partitioning_views` / `drop_partitioning_views` creates or drops the PostgreSQL views
|
|
98
|
+
(`postgres_partitioned_tables`, `postgres_partitions`, `postgres_detached_partitions`) that the
|
|
99
|
+
partition manager uses to inspect existing partitions.
|
|
100
|
+
|
|
101
|
+
Example migration:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class CreatePartitionedEvents < Code0::ZeroTrack::Database::Migration[1.0]
|
|
105
|
+
def change
|
|
106
|
+
create_dynamic_partition_schema
|
|
107
|
+
create_partitioning_views
|
|
108
|
+
|
|
109
|
+
create_partition_by_date_table :events, partition_column: :created_at do |t|
|
|
110
|
+
t.text :name, null: false
|
|
111
|
+
t.timestamps_with_timezone null: false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The schema and views only need to be created once before creating the first partitioned table.
|
|
118
|
+
|
|
119
|
+
Tables don't necessarily have to be created with the provided helper. The partition manager will
|
|
120
|
+
work as long as the model is correctly configured.
|
|
121
|
+
|
|
122
|
+
#### Defining a Partitioned Model
|
|
123
|
+
|
|
124
|
+
Include `Code0::ZeroTrack::Database::Partitioning::PartitionedTable` in your model and declare
|
|
125
|
+
the partitioning strategy:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class Event < ApplicationRecord
|
|
129
|
+
include Code0::ZeroTrack::Database::Partitioning::PartitionedTable
|
|
130
|
+
|
|
131
|
+
partition_by :created_at, strategy: :monthly, retain_for: 12.months
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Available strategies: `:daily` and `:monthly`.
|
|
136
|
+
|
|
137
|
+
Options passed to `partition_by`:
|
|
138
|
+
|
|
139
|
+
| Option | Description | Default |
|
|
140
|
+
|--------|-------------|---------|
|
|
141
|
+
| `strategy` | `:daily` or `:monthly` | *required* |
|
|
142
|
+
| `headroom` | How far ahead to pre-create partitions | 30 days (daily) / 6 months (monthly) |
|
|
143
|
+
| `retain_for` | How long to keep partitions before detaching (enables retention) | `nil` (disabled) |
|
|
144
|
+
| `retain_detached_for` | How long to keep detached partitions before dropping | 7 days |
|
|
145
|
+
|
|
146
|
+
#### Partition Manager
|
|
147
|
+
|
|
148
|
+
Register models for automatic partition management:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
Code0::ZeroTrack::Database::Partitioning::PartitionManager.register_model(Event)
|
|
152
|
+
Code0::ZeroTrack::Database::Partitioning::PartitionManager.register_model(EventDetail)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Then synchronize all registered models. The gem won't run this for you.
|
|
156
|
+
Call it from a cron job, Sidekiq scheduler, or deploy script:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
Code0::ZeroTrack::Database::Partitioning::PartitionManager.sync_all_partitions!
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Or manage a single model:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
manager = Code0::ZeroTrack::Database::Partitioning::PartitionManager.new(Event)
|
|
166
|
+
manager.sync_partitions!
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`sync_all_partitions!` first creates partitions for all registered models, then detaches and drops
|
|
170
|
+
partitions for all models in reverse registration order.
|
|
171
|
+
|
|
172
|
+
`sync_partitions!` performs three operations in order for a single model:
|
|
173
|
+
1. Create and attach new partitions to cover the desired range (up to the configured headroom).
|
|
174
|
+
2. Detach partitions that fall outside the desired range (when retention is enabled).
|
|
175
|
+
3. Drop detached partitions that have been detached longer than `retain_detached_for`.
|
|
176
|
+
|
|
177
|
+
If needed, the three phases can be called individually with `create_partitions!`, `detach_partitions!`
|
|
178
|
+
and `drop_partitions!`. This only works on a partition manager for a specific model. There is no
|
|
179
|
+
shortcut to run this on all registered models like the `sync_all_partitions!` method.
|
|
180
|
+
|
|
181
|
+
Each table gets a PostgreSQL advisory lock, so concurrent calls won't conflict.
|
|
182
|
+
|
|
183
|
+
When tables have foreign key relationships, registration order and retention configuration matter:
|
|
184
|
+
|
|
185
|
+
- Register parent tables before child tables. `sync_all_partitions!` creates partitions in
|
|
186
|
+
registration order and detaches/drops them in reverse order. This way the parent tables
|
|
187
|
+
are created before the children and dropped after their children.
|
|
188
|
+
- A child table's `retain_for` must be less than or equal to the parent table's `retain_for`.
|
|
189
|
+
If a child retains partitions longer than its parent, dropping the parent partition will
|
|
190
|
+
fail because the child's foreign key still references it.
|
|
191
|
+
|
|
192
|
+
#### Schema Cleaner Integration
|
|
193
|
+
|
|
194
|
+
Dynamic partition tables are automatically removed from `db/structure.sql` if the
|
|
195
|
+
[schema cleaner](#configzero_trackactive_recordschema_cleaner) is enabled.
|
|
@@ -11,6 +11,7 @@ module Code0
|
|
|
11
11
|
include Database::MigrationHelpers::IndexHelpers
|
|
12
12
|
include Database::MigrationHelpers::RemoveColumnEnhancements
|
|
13
13
|
include Database::MigrationHelpers::TableEnhancements
|
|
14
|
+
include Database::MigrationHelpers::TablePartitioning
|
|
14
15
|
end
|
|
15
16
|
# rubocop:enable Naming/ClassAndModuleCamelCase
|
|
16
17
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module MigrationHelpers
|
|
7
|
+
module TablePartitioning
|
|
8
|
+
def create_partition_by_date_table(table_name, partition_column:, **options, &block)
|
|
9
|
+
options[:options] = "PARTITION BY RANGE (#{quote_column_name(partition_column)})"
|
|
10
|
+
options[:id] = false
|
|
11
|
+
|
|
12
|
+
create_table(table_name, **options) do |t|
|
|
13
|
+
t.bigserial :id, null: false
|
|
14
|
+
|
|
15
|
+
block.call(t)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
reversible do |dir|
|
|
19
|
+
dir.up do
|
|
20
|
+
execute <<~SQL.squish
|
|
21
|
+
ALTER TABLE #{quote_table_name(table_name)}
|
|
22
|
+
ADD PRIMARY KEY (#{quote_column_name(:id)}, #{quote_column_name(partition_column)})
|
|
23
|
+
SQL
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_dynamic_partition_schema
|
|
29
|
+
schema = quote_table_name(Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema)
|
|
30
|
+
execute "CREATE SCHEMA #{schema}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def drop_dynamic_partition_schema
|
|
34
|
+
schema = quote_table_name(Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema)
|
|
35
|
+
execute "DROP SCHEMA #{schema}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_partitioning_views
|
|
39
|
+
dynamic_schema = Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema
|
|
40
|
+
|
|
41
|
+
execute <<-SQL.squish
|
|
42
|
+
CREATE OR REPLACE VIEW postgres_partitioned_tables AS
|
|
43
|
+
SELECT c.oid::regclass::text AS identifier,
|
|
44
|
+
c.oid,
|
|
45
|
+
n.nspname AS schema,
|
|
46
|
+
c.relname AS name,
|
|
47
|
+
CASE p.partstrat
|
|
48
|
+
WHEN 'l' THEN 'list'
|
|
49
|
+
WHEN 'r' THEN 'range'
|
|
50
|
+
WHEN 'h' THEN 'hash'
|
|
51
|
+
END AS strategy,
|
|
52
|
+
pg_get_partkeydef(c.oid) AS partition_key
|
|
53
|
+
FROM pg_partitioned_table p
|
|
54
|
+
JOIN pg_class c ON c.oid = p.partrelid
|
|
55
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
56
|
+
WHERE n.nspname = current_schema();
|
|
57
|
+
SQL
|
|
58
|
+
|
|
59
|
+
execute <<-SQL.squish
|
|
60
|
+
CREATE OR REPLACE VIEW postgres_partitions AS
|
|
61
|
+
SELECT c.oid::regclass::text AS identifier,
|
|
62
|
+
c.oid,
|
|
63
|
+
n.nspname AS schema,
|
|
64
|
+
c.relname AS name,
|
|
65
|
+
i.inhparent::regclass::text AS parent_identifier,
|
|
66
|
+
pg_get_expr(c.relpartbound, c.oid) AS condition,
|
|
67
|
+
obj_description(c.oid) AS comment,
|
|
68
|
+
i.inhrelid IS NOT NULL AS attached
|
|
69
|
+
FROM pg_class c
|
|
70
|
+
LEFT JOIN pg_inherits i ON c.oid = i.inhrelid
|
|
71
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
72
|
+
WHERE c.relispartition
|
|
73
|
+
AND c.relkind = 'r'
|
|
74
|
+
AND n.nspname IN (current_schema(), #{quote(dynamic_schema)});
|
|
75
|
+
SQL
|
|
76
|
+
|
|
77
|
+
execute <<-SQL.squish
|
|
78
|
+
CREATE OR REPLACE VIEW postgres_detached_partitions AS
|
|
79
|
+
SELECT c.oid::regclass::text AS identifier,
|
|
80
|
+
c.oid,
|
|
81
|
+
n.nspname AS schema,
|
|
82
|
+
c.relname AS name,
|
|
83
|
+
obj_description(c.oid)::jsonb ->> 'table' AS parent_identifier,
|
|
84
|
+
(obj_description(c.oid)::jsonb ->> 'detached_at')::timestamptz AS detached_at
|
|
85
|
+
FROM pg_class c
|
|
86
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
87
|
+
WHERE c.relkind = 'r'
|
|
88
|
+
AND n.nspname = #{quote(dynamic_schema)}
|
|
89
|
+
AND NOT EXISTS (
|
|
90
|
+
SELECT 1 FROM pg_inherits WHERE inhrelid = c.oid
|
|
91
|
+
)
|
|
92
|
+
AND obj_description(c.oid)::jsonb ? 'table'
|
|
93
|
+
AND obj_description(c.oid)::jsonb ? 'detached_at';
|
|
94
|
+
SQL
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def drop_partitioning_views
|
|
98
|
+
execute 'DROP VIEW IF EXISTS postgres_detached_partitions'
|
|
99
|
+
execute 'DROP VIEW IF EXISTS postgres_partitions'
|
|
100
|
+
execute 'DROP VIEW IF EXISTS postgres_partitioned_tables'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Code0
|
|
6
|
+
module ZeroTrack
|
|
7
|
+
module Database
|
|
8
|
+
module Partitioning
|
|
9
|
+
class PartitionManager
|
|
10
|
+
include Loggable
|
|
11
|
+
|
|
12
|
+
cattr_accessor :models
|
|
13
|
+
self.models = []
|
|
14
|
+
|
|
15
|
+
def self.register_model(clazz)
|
|
16
|
+
models << clazz
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.reset_registered_models!
|
|
20
|
+
models.clear
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.sync_all_partitions!
|
|
24
|
+
models.each do |model|
|
|
25
|
+
new(model).create_partitions!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
models.reverse_each do |model|
|
|
29
|
+
manager = new(model)
|
|
30
|
+
manager.detach_partitions!
|
|
31
|
+
manager.drop_partitions!
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :model
|
|
36
|
+
|
|
37
|
+
def initialize(model)
|
|
38
|
+
if model.try(:partitioning_strategy).nil?
|
|
39
|
+
raise ArgumentError, "Model #{model} not configured for partitioning"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@model = model
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sync_partitions!
|
|
46
|
+
create_partitions!
|
|
47
|
+
|
|
48
|
+
detach_partitions!
|
|
49
|
+
|
|
50
|
+
drop_partitions!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create_partitions!
|
|
54
|
+
with_lock do |connection|
|
|
55
|
+
model.partitioning_strategy.partitions_to_create.each do |partition|
|
|
56
|
+
create_partition(partition, connection)
|
|
57
|
+
attach_partition(partition, connection)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def detach_partitions!
|
|
63
|
+
with_lock do |connection|
|
|
64
|
+
model.partitioning_strategy.partitions_to_detach.each do |partition|
|
|
65
|
+
detach_partition(partition, connection)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def drop_partitions!
|
|
71
|
+
with_lock do |connection|
|
|
72
|
+
model.partitioning_strategy.partitions_to_drop.each do |detached_partition|
|
|
73
|
+
drop_partition(detached_partition, connection)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def create_partition(partition, connection)
|
|
81
|
+
connection.execute(partition.to_create_sql(connection))
|
|
82
|
+
logger.info(
|
|
83
|
+
message: 'Created new partition',
|
|
84
|
+
table_name: partition.model.table_name,
|
|
85
|
+
partition_name: partition.partition_name
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def attach_partition(partition, connection)
|
|
90
|
+
connection.execute(partition.to_attach_sql(connection))
|
|
91
|
+
logger.info(
|
|
92
|
+
message: 'Attached partition',
|
|
93
|
+
table_name: partition.model.table_name,
|
|
94
|
+
partition_name: partition.partition_name
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def detach_partition(partition, connection)
|
|
99
|
+
connection.execute(partition.to_detach_sql(connection))
|
|
100
|
+
|
|
101
|
+
partition_comment = connection.quote({ table: model.table_name, detached_at: Time.current.iso8601 }.to_json)
|
|
102
|
+
fully_qualified_partition = partition.fully_qualified_partition(connection)
|
|
103
|
+
connection.execute("COMMENT ON TABLE #{fully_qualified_partition} IS #{partition_comment}")
|
|
104
|
+
|
|
105
|
+
logger.info(
|
|
106
|
+
message: 'Detached partition',
|
|
107
|
+
table_name: partition.model.table_name,
|
|
108
|
+
partition_name: partition.partition_name
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def drop_partition(detached_partition, connection)
|
|
113
|
+
schema_name = connection.quote_table_name(detached_partition.schema)
|
|
114
|
+
partition_name = connection.quote_table_name(detached_partition.name)
|
|
115
|
+
qualified_name = "#{schema_name}.#{partition_name}"
|
|
116
|
+
connection.execute("DROP TABLE #{qualified_name}")
|
|
117
|
+
|
|
118
|
+
logger.info(
|
|
119
|
+
message: 'Dropped partition',
|
|
120
|
+
table_name: detached_partition.parent_identifier,
|
|
121
|
+
partition_name: detached_partition.name
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def with_lock
|
|
126
|
+
lock_key = lock_key_for(model.table_name)
|
|
127
|
+
|
|
128
|
+
with_connection do |connection|
|
|
129
|
+
connection.transaction do
|
|
130
|
+
connection.execute("SELECT pg_advisory_xact_lock(#{lock_key})")
|
|
131
|
+
yield connection
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def lock_key_for(table_name)
|
|
137
|
+
namespace = 'zero_track:partition_sync'
|
|
138
|
+
Zlib.crc32("#{namespace}:#{table_name}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def with_connection(&block)
|
|
142
|
+
model.with_connection(&block)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
module PartitionedTable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
PARTITIONING_STRATEGIES = {
|
|
11
|
+
daily: Partitioning::Strategy::Daily,
|
|
12
|
+
monthly: Partitioning::Strategy::Monthly,
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
class_methods do
|
|
16
|
+
attr_reader :partitioning_strategy
|
|
17
|
+
|
|
18
|
+
def partition_by(column, strategy:, **kwargs)
|
|
19
|
+
raise(ArgumentError, 'Table is already partitioned') unless partitioning_strategy.nil?
|
|
20
|
+
|
|
21
|
+
strategy_class = PARTITIONING_STRATEGIES[strategy] || raise(
|
|
22
|
+
ArgumentError,
|
|
23
|
+
"Unknown partitioning strategy: #{strategy}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@partitioning_strategy = strategy_class.new(self, column, **kwargs)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
class PostgresDetachedPartition < Rails.application.config.zero_track.db_partitioning.base_ar_class.constantize
|
|
8
|
+
self.table_name = 'postgres_detached_partitions'
|
|
9
|
+
self.primary_key = 'identifier'
|
|
10
|
+
|
|
11
|
+
def readonly?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
scope :detached_before, ->(timestamp) { where(detached_at: ..timestamp) }
|
|
16
|
+
|
|
17
|
+
belongs_to :postgres_partitioned_table,
|
|
18
|
+
class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresPartitionedTable',
|
|
19
|
+
foreign_key: 'parent_identifier',
|
|
20
|
+
primary_key: 'identifier',
|
|
21
|
+
inverse_of: :postgres_detached_partitions
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
class PostgresPartition < Rails.application.config.zero_track.db_partitioning.base_ar_class.constantize
|
|
8
|
+
self.table_name = 'postgres_partitions'
|
|
9
|
+
self.primary_key = 'identifier'
|
|
10
|
+
|
|
11
|
+
def readonly?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
belongs_to :postgres_partitioned_table,
|
|
16
|
+
class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresPartitionedTable',
|
|
17
|
+
foreign_key: 'parent_identifier',
|
|
18
|
+
primary_key: 'identifier',
|
|
19
|
+
inverse_of: :postgres_partitions
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
class PostgresPartitionedTable < Rails.application.config.zero_track.db_partitioning.base_ar_class.constantize
|
|
8
|
+
self.table_name = 'postgres_partitioned_tables'
|
|
9
|
+
self.primary_key = 'identifier'
|
|
10
|
+
|
|
11
|
+
def readonly?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
has_many :postgres_partitions,
|
|
16
|
+
class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresPartition',
|
|
17
|
+
foreign_key: 'parent_identifier',
|
|
18
|
+
primary_key: 'identifier',
|
|
19
|
+
inverse_of: :postgres_partitioned_table
|
|
20
|
+
|
|
21
|
+
has_many :postgres_detached_partitions,
|
|
22
|
+
class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresDetachedPartition',
|
|
23
|
+
foreign_key: 'parent_identifier',
|
|
24
|
+
primary_key: 'identifier',
|
|
25
|
+
inverse_of: :postgres_partitioned_table
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
module Strategy
|
|
8
|
+
class Base
|
|
9
|
+
attr_reader :model, :partitioning_column, :headroom, :retain_for, :retain_detached_for
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
model,
|
|
13
|
+
partitioning_column,
|
|
14
|
+
headroom: default_headroom,
|
|
15
|
+
retain_for: nil,
|
|
16
|
+
retain_detached_for: 7.days
|
|
17
|
+
)
|
|
18
|
+
@model = model
|
|
19
|
+
@partitioning_column = partitioning_column
|
|
20
|
+
@headroom = headroom
|
|
21
|
+
@retain_for = retain_for
|
|
22
|
+
@retain_detached_for = retain_detached_for
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def current_partitions
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def desired_partitions
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def oldest_active_date
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def partition_name(lower_bound)
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_headroom
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def partitions_to_create
|
|
46
|
+
desired_partitions - current_partitions
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def partitions_to_detach
|
|
50
|
+
current_partitions - desired_partitions
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def partitions_to_drop
|
|
54
|
+
partitioned_table = PostgresPartitionedTable.find_by(identifier: model.table_name)
|
|
55
|
+
|
|
56
|
+
if partitioned_table.nil?
|
|
57
|
+
logger.warn(message: 'Failed to find partitioned table', identifier: model.table_name)
|
|
58
|
+
return []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
partitioned_table.postgres_detached_partitions.detached_before(retain_detached_for.ago)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def retention_enabled?
|
|
65
|
+
retain_for.present?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
module Strategy
|
|
8
|
+
class Daily < Time
|
|
9
|
+
PARTITION_SUFFIX = 'Y%YM%mD%d'
|
|
10
|
+
|
|
11
|
+
def advance_date(date)
|
|
12
|
+
date + 1.day
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def normalize_date(date)
|
|
16
|
+
date.beginning_of_day.to_date
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def partition_name(lower_bound)
|
|
20
|
+
suffix = lower_bound&.strftime(PARTITION_SUFFIX) || 'Y0000M00D00'
|
|
21
|
+
|
|
22
|
+
"#{model.table_name}_#{suffix}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_headroom
|
|
26
|
+
30.days
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
module Strategy
|
|
8
|
+
class Monthly < Time
|
|
9
|
+
PARTITION_SUFFIX = 'Y%YM%m'
|
|
10
|
+
|
|
11
|
+
def advance_date(date)
|
|
12
|
+
date.next_month
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def normalize_date(date)
|
|
16
|
+
date.beginning_of_month.to_date
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def partition_name(lower_bound)
|
|
20
|
+
suffix = lower_bound&.strftime(PARTITION_SUFFIX) || 'Y0000M00'
|
|
21
|
+
|
|
22
|
+
"#{model.table_name}_#{suffix}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_headroom
|
|
26
|
+
6.months
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
module Strategy
|
|
8
|
+
class Time < Base
|
|
9
|
+
include Loggable
|
|
10
|
+
|
|
11
|
+
def current_partitions
|
|
12
|
+
partitioned_table = PostgresPartitionedTable.find_by(identifier: model.table_name)
|
|
13
|
+
|
|
14
|
+
if partitioned_table.nil?
|
|
15
|
+
logger.warn(message: 'Failed to find partitioned table', identifier: model.table_name)
|
|
16
|
+
return []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
partitioned_table.postgres_partitions.map do |partition|
|
|
20
|
+
TimePartition.from_sql(model, partition.name, partition.condition)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def desired_partitions
|
|
25
|
+
partitions = []
|
|
26
|
+
|
|
27
|
+
min_date, max_date = desired_range
|
|
28
|
+
|
|
29
|
+
while min_date < max_date
|
|
30
|
+
next_date = advance_date(min_date)
|
|
31
|
+
|
|
32
|
+
partitions << TimePartition.new(
|
|
33
|
+
model,
|
|
34
|
+
min_date,
|
|
35
|
+
next_date,
|
|
36
|
+
partition_name: partition_name(min_date)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
min_date = next_date
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
partitions
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def desired_range
|
|
46
|
+
if retention_enabled?
|
|
47
|
+
min_date = oldest_active_date
|
|
48
|
+
else
|
|
49
|
+
first_partition = current_partitions.min
|
|
50
|
+
|
|
51
|
+
min_date = first_partition.from || first_partition.to if first_partition
|
|
52
|
+
min_date ||= Date.current
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
min_date = normalize_date(min_date)
|
|
56
|
+
|
|
57
|
+
max_date = advance_date(Date.current) + headroom
|
|
58
|
+
|
|
59
|
+
[min_date, max_date]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def oldest_active_date
|
|
63
|
+
normalize_date(retain_for.ago)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def advance_date(date)
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def normalize_date(date)
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Code0
|
|
4
|
+
module ZeroTrack
|
|
5
|
+
module Database
|
|
6
|
+
module Partitioning
|
|
7
|
+
class TimePartition
|
|
8
|
+
include Comparable
|
|
9
|
+
|
|
10
|
+
def self.from_sql(table, partition_name, definition)
|
|
11
|
+
matches = definition.match(/FOR VALUES FROM \('?(?<from>[^)']+)'?\) TO \('?(?<to>[^)']+)'?\)/)
|
|
12
|
+
|
|
13
|
+
raise ArgumentError, "Unknown partition definition: #{definition}" unless matches
|
|
14
|
+
|
|
15
|
+
raise NotImplementedError, 'MAXVALUE as upper bound is not supported' if matches[:to] == 'MAXVALUE'
|
|
16
|
+
|
|
17
|
+
from = matches[:from] == 'MINVALUE' ? nil : matches[:from]
|
|
18
|
+
to = matches[:to]
|
|
19
|
+
|
|
20
|
+
new(table, from, to, partition_name: partition_name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :model, :from, :to, :partition_name
|
|
24
|
+
|
|
25
|
+
def initialize(model, from, to, partition_name:)
|
|
26
|
+
@model = model
|
|
27
|
+
@from = date_or_nil(from)
|
|
28
|
+
@to = date_or_nil(to)
|
|
29
|
+
@partition_name = partition_name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ==(other)
|
|
33
|
+
model == other.model && partition_name == other.partition_name && from == other.from && to == other.to
|
|
34
|
+
end
|
|
35
|
+
alias eql? ==
|
|
36
|
+
|
|
37
|
+
def hash
|
|
38
|
+
[model, partition_name, from, to].hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def <=>(other)
|
|
42
|
+
return if model != other.model
|
|
43
|
+
|
|
44
|
+
partition_name <=> other.partition_name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_create_sql(connection)
|
|
48
|
+
<<~SQL.squish
|
|
49
|
+
CREATE TABLE IF NOT EXISTS #{fully_qualified_partition(connection)}
|
|
50
|
+
(LIKE #{connection.quote_table_name(model.table_name)} INCLUDING ALL)
|
|
51
|
+
SQL
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_attach_sql(connection)
|
|
55
|
+
from_sql = from ? connection.quote(from.to_date.iso8601) : 'MINVALUE'
|
|
56
|
+
to_sql = connection.quote(to.to_date.iso8601)
|
|
57
|
+
|
|
58
|
+
<<~SQL.squish
|
|
59
|
+
ALTER TABLE #{connection.quote_table_name(model.table_name)}
|
|
60
|
+
ATTACH PARTITION #{fully_qualified_partition(connection)}
|
|
61
|
+
FOR VALUES FROM (#{from_sql}) TO (#{to_sql})
|
|
62
|
+
SQL
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_detach_sql(connection)
|
|
66
|
+
<<~SQL.squish
|
|
67
|
+
ALTER TABLE #{connection.quote_table_name(model.table_name)}
|
|
68
|
+
DETACH PARTITION #{fully_qualified_partition(connection)}
|
|
69
|
+
SQL
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fully_qualified_partition(connection)
|
|
73
|
+
format(
|
|
74
|
+
'%<schema>s.%<partition>s',
|
|
75
|
+
schema: connection.quote_table_name(
|
|
76
|
+
Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema
|
|
77
|
+
),
|
|
78
|
+
partition: connection.quote_table_name(partition_name)
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def date_or_nil(obj)
|
|
85
|
+
return unless obj
|
|
86
|
+
return obj if obj.is_a?(Date)
|
|
87
|
+
|
|
88
|
+
Date.parse(obj)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -38,6 +38,10 @@ module Code0
|
|
|
38
38
|
'CREATE EXTENSION IF NOT EXISTS \1;'
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
+
# Remove dynamic partition objects that are managed automatically at runtime.
|
|
42
|
+
# These would cause schema drift on every partition rotation if left in the dump.
|
|
43
|
+
remove_dynamic_partitions!(structure)
|
|
44
|
+
|
|
41
45
|
structure.gsub!(/\n{3,}/, "\n\n")
|
|
42
46
|
|
|
43
47
|
io << structure.strip
|
|
@@ -45,6 +49,31 @@ module Code0
|
|
|
45
49
|
|
|
46
50
|
nil
|
|
47
51
|
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def dynamic_partition_schema
|
|
56
|
+
Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def remove_dynamic_partitions!(structure)
|
|
60
|
+
schema = Regexp.escape(dynamic_partition_schema)
|
|
61
|
+
|
|
62
|
+
# Remove CREATE TABLE <schema>.<partition> (...);
|
|
63
|
+
structure.gsub!(/^CREATE TABLE #{schema}\.\S+\s*\(.*?\);\n/m, '')
|
|
64
|
+
|
|
65
|
+
# Remove ALTER TABLE ... ATTACH PARTITION <schema>.<partition> ...;
|
|
66
|
+
structure.gsub!(/^ALTER TABLE .+ ATTACH PARTITION #{schema}\.\S+.*?;\n/, '')
|
|
67
|
+
|
|
68
|
+
# Remove ALTER TABLE ONLY <schema>.<partition> ...;
|
|
69
|
+
structure.gsub!(/^ALTER TABLE ONLY #{schema}\.\S+\n.*?;\n/m, '')
|
|
70
|
+
|
|
71
|
+
# Remove CREATE [UNIQUE] INDEX ... ON <schema>.<partition> ...;
|
|
72
|
+
structure.gsub!(/^CREATE (?:UNIQUE )?INDEX \S+ ON #{schema}\.\S+.*?;\n/m, '')
|
|
73
|
+
|
|
74
|
+
# Remove ALTER INDEX ... ATTACH PARTITION <schema>.<partition>;
|
|
75
|
+
structure.gsub!(/^ALTER INDEX \S+ ATTACH PARTITION #{schema}\.\S+;\n/, '')
|
|
76
|
+
end
|
|
48
77
|
end
|
|
49
78
|
end
|
|
50
79
|
end
|
|
@@ -4,11 +4,16 @@ module Code0
|
|
|
4
4
|
module ZeroTrack
|
|
5
5
|
class Railtie < ::Rails::Railtie
|
|
6
6
|
config.zero_track = ActiveSupport::OrderedOptions.new
|
|
7
|
+
|
|
7
8
|
config.zero_track.active_record = ActiveSupport::OrderedOptions.new
|
|
8
9
|
config.zero_track.active_record.timestamps = false
|
|
9
10
|
config.zero_track.active_record.schema_migrations = false
|
|
10
11
|
config.zero_track.active_record.schema_cleaner = false
|
|
11
12
|
|
|
13
|
+
config.zero_track.db_partitioning = ActiveSupport::OrderedOptions.new
|
|
14
|
+
config.zero_track.db_partitioning.dynamic_partition_schema = 'partitions_dynamic'
|
|
15
|
+
config.zero_track.db_partitioning.base_ar_class = 'ActiveRecord::Base'
|
|
16
|
+
|
|
12
17
|
rake_tasks do
|
|
13
18
|
path = File.expand_path(__dir__)
|
|
14
19
|
Dir.glob("#{path}/../../tasks/**/*.rake").each { |f| load f }
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: code0-zero_track
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Niklas van Schrick
|
|
@@ -167,6 +167,17 @@ files:
|
|
|
167
167
|
- lib/code0/zero_track/database/migration_helpers/index_helpers.rb
|
|
168
168
|
- lib/code0/zero_track/database/migration_helpers/remove_column_enhancements.rb
|
|
169
169
|
- lib/code0/zero_track/database/migration_helpers/table_enhancements.rb
|
|
170
|
+
- lib/code0/zero_track/database/migration_helpers/table_partitioning.rb
|
|
171
|
+
- lib/code0/zero_track/database/partitioning/partition_manager.rb
|
|
172
|
+
- lib/code0/zero_track/database/partitioning/partitioned_table.rb
|
|
173
|
+
- lib/code0/zero_track/database/partitioning/postgres_detached_partition.rb
|
|
174
|
+
- lib/code0/zero_track/database/partitioning/postgres_partition.rb
|
|
175
|
+
- lib/code0/zero_track/database/partitioning/postgres_partitioned_table.rb
|
|
176
|
+
- lib/code0/zero_track/database/partitioning/strategy/base.rb
|
|
177
|
+
- lib/code0/zero_track/database/partitioning/strategy/daily.rb
|
|
178
|
+
- lib/code0/zero_track/database/partitioning/strategy/monthly.rb
|
|
179
|
+
- lib/code0/zero_track/database/partitioning/strategy/time.rb
|
|
180
|
+
- lib/code0/zero_track/database/partitioning/time_partition.rb
|
|
170
181
|
- lib/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin.rb
|
|
171
182
|
- lib/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin.rb
|
|
172
183
|
- lib/code0/zero_track/database/schema_cleaner.rb
|