tenant_partition 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +199 -0
- data/Rakefile +8 -0
- data/lib/activepartition/base.rb +167 -0
- data/lib/activepartition/concerns/controller.rb +71 -0
- data/lib/activepartition/concerns/partitioned.rb +118 -0
- data/lib/activepartition/configuration.rb +33 -0
- data/lib/activepartition/railtie.rb +39 -0
- data/lib/activepartition/safety_guard.rb +48 -0
- data/lib/activepartition/schema/statements.rb +66 -0
- data/lib/activepartition/tasks/maintenance.rake +14 -0
- data/lib/activepartition/version.rb +5 -0
- data/lib/activepartition.rb +214 -0
- data/sig/activepartition.rbs +4 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a64a55d18e146680cc0efa637a7551edd6c04e724f76ebcfed9024ee47f4d33a
|
|
4
|
+
data.tar.gz: 94486994c67028a1564f65de2f1b4ec40a9bea9b36d9f52634cfe3bf83c0687e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 24b43da02e5216554e7b5b7f343a0a5a298e9c3b3e10c6525e8c8e90e36f84782d0c51c2ac06748906a357e287a621ab47c667159799d2915b13426dd30b7a5c
|
|
7
|
+
data.tar.gz: 421aeb63c9ea4230e25da6ebe09bec7107de32b3fdd2eff0a5291c390937dfd24c6a3223ef1fdec096b6205f43fa5342c584cd811e01321be3a0636d21d883b7
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gabriel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# ActivePartition
|
|
2
|
+
|
|
3
|
+
**ActivePartition** es un framework de infraestructura para Ruby on Rails (7.1+) diseñado para simplificar y automatizar la gestión de **Particionamiento por Lista (List Partitioning)** nativo de PostgreSQL.
|
|
4
|
+
|
|
5
|
+
Específicamente construido para arquitecturas **Multi-tenant**, este framework resuelve la complejidad de:
|
|
6
|
+
1. **Composite Primary Keys (CPK):** Configuración automática de claves compuestas (`id` + `partition_key`) requeridas por ActiveRecord para soportar particionamiento.
|
|
7
|
+
2. **Orquestación Centralizada:** Una única interfaz para crear y eliminar particiones en **todos** los modelos de la aplicación simultáneamente.
|
|
8
|
+
3. **Mantenimiento Zero-Downtime:** Migración atómica de datos "huérfanos" (que cayeron en la tabla `DEFAULT`) hacia sus particiones correctas sin perder servicio.
|
|
9
|
+
|
|
10
|
+
## 🚀 Características Principales
|
|
11
|
+
|
|
12
|
+
* **Fachada de Servicio:** API unificada (`ActivePartition.create!`, `destroy!`, `audit`, `cleanup!`) para gestionar el ciclo de vida completo de los tenants.
|
|
13
|
+
* **Introspección Inteligente:** Los modelos de negocio detectan automáticamente su configuración de infraestructura por convención (ej: `User` -> `Partition::User`).
|
|
14
|
+
* **Migration DSL:** Helper `create_partitioned_table` para definir tablas particionadas y sus tablas `DEFAULT` en una sola instrucción.
|
|
15
|
+
* **Seguridad Estricta:** Concern para controladores que valida Headers HTTP (`X-Tenant-ID`) para asegurar el contexto del tenant.
|
|
16
|
+
* **Observabilidad:** Instrumentación integrada con `ActiveSupport::Notifications`.
|
|
17
|
+
|
|
18
|
+
## 📋 Requisitos
|
|
19
|
+
|
|
20
|
+
* **Ruby:** >= 3.2
|
|
21
|
+
* **Ruby on Rails:** >= 7.1
|
|
22
|
+
* **PostgreSQL:** >= 13.0 (Requerido para `gen_random_uuid()` nativo)
|
|
23
|
+
|
|
24
|
+
## 📦 Instalación
|
|
25
|
+
|
|
26
|
+
Agrega esto a tu `Gemfile`:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
gem 'tenant_partition'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Luego ejecuta:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bundle install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## ⚙️ Configuración
|
|
39
|
+
|
|
40
|
+
Crea un inicializador en `config/initializers/activepartition.rb`. Es **obligatorio** definir la clave de partición.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
ActivePartition.configure do |config|
|
|
44
|
+
# 1. La columna que discrimina los tenants (ej: :isp_id, :account_id, :tenant_id)
|
|
45
|
+
config.partition_key = :isp_id
|
|
46
|
+
|
|
47
|
+
# 2. El Header HTTP para la seguridad en controladores API
|
|
48
|
+
config.header_name = 'X-Tenant-ID'
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 🛠 Guía de Uso
|
|
53
|
+
|
|
54
|
+
### 1. Migraciones (Crear las Tablas)
|
|
55
|
+
|
|
56
|
+
Usa el helper `create_partitioned_table`. Este método deshabilita el ID automático simple y configura una **Primary Key Compuesta** (`[:id, :partition_key]`) necesaria para que PostgreSQL permita el particionamiento.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
class CreateConversations < ActiveRecord::Migration[7.1]
|
|
60
|
+
def change
|
|
61
|
+
create_partitioned_table :conversations do |t|
|
|
62
|
+
# No definas t.primary_key. La gema crea (id, isp_id) automáticamente.
|
|
63
|
+
t.string :topic
|
|
64
|
+
t.timestamps
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 2. Capa de Infraestructura (Modelos Partition)
|
|
71
|
+
|
|
72
|
+
Define modelos que hereden de `ActivePartition::Base`. Estos modelos son responsables de las operaciones DDL (Create/Drop tables). Por convención, se recomienda usar el namespace `Partition::`.
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# app/models/partition/conversation.rb
|
|
76
|
+
module Partition
|
|
77
|
+
class Conversation < ActivePartition::Base
|
|
78
|
+
# Hereda la configuración global (:isp_id) automáticamente.
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 3. Capa de Negocio (Modelos Rails)
|
|
84
|
+
|
|
85
|
+
En tus modelos estándar (`ApplicationRecord`), incluye el concern `Partitioned`.
|
|
86
|
+
|
|
87
|
+
**Magia de Introspección:**
|
|
88
|
+
Al incluir el concern, la gema busca automáticamente si existe un modelo de infraestructura asociado (ej: `Partition::Conversation`) y hereda su configuración.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# app/models/conversation.rb
|
|
92
|
+
class Conversation < ApplicationRecord
|
|
93
|
+
include ActivePartition::Concerns::Partitioned
|
|
94
|
+
|
|
95
|
+
# ¡Listo! Rails ahora sabe que la Primary Key es [:id, :isp_id]
|
|
96
|
+
# y aplica scopes automáticos.
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 4. Orquestación (Ciclo de Vida del Tenant)
|
|
101
|
+
|
|
102
|
+
Ya no necesitas crear particiones tabla por tabla. Usa la **Fachada** `ActivePartition` para gestionar la infraestructura de un tenant en **todos** los modelos registrados simultáneamente.
|
|
103
|
+
|
|
104
|
+
**Crear un nuevo Tenant (Provisioning):**
|
|
105
|
+
Ideal para usar en tu `RegistrationService` o `AfterCommit` de la creación del tenant.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# En tu Service Object o Controller de registro
|
|
109
|
+
def create_tenant
|
|
110
|
+
isp = Isp.create!(params)
|
|
111
|
+
|
|
112
|
+
# Busca TODOS los modelos particionados y crea las tablas físicas para este ID.
|
|
113
|
+
# Es idempotente: si alguna ya existe, la salta sin error.
|
|
114
|
+
ActivePartition.create!(isp.id)
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Eliminar un Tenant (Deprovisioning):**
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# Esta operación realiza DETACH + DROP de las tablas físicas.
|
|
122
|
+
# ¡Es destructiva e irreversible!
|
|
123
|
+
ActivePartition.destroy!(old_isp.id)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 5. Seguridad en Controladores
|
|
127
|
+
|
|
128
|
+
Protege tus API endpoints asegurando que siempre reciban el ID del tenant.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class ApiController < ActionController::API
|
|
132
|
+
include ActivePartition::Concerns::Controller
|
|
133
|
+
|
|
134
|
+
# Valida que el request traiga el header 'X-Tenant-ID'.
|
|
135
|
+
# Devuelve 400 Bad Request si falta.
|
|
136
|
+
before_action :require_partition_key!
|
|
137
|
+
|
|
138
|
+
def index
|
|
139
|
+
# current_partition_id contiene el valor seguro del Header
|
|
140
|
+
@chats = Conversation.for_partition(current_partition_id).all
|
|
141
|
+
render json: @chats
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## 🛡 Mantenimiento y Recuperación
|
|
147
|
+
|
|
148
|
+
ActivePartition maneja el escenario de "Race Condition" donde llegan datos *antes* de que la partición exista. Esos datos caen automáticamente en la tabla `_default`.
|
|
149
|
+
|
|
150
|
+
### Auditoría
|
|
151
|
+
|
|
152
|
+
Verifica si tienes datos "fugados" en las tablas default:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# Desde consola Rails
|
|
156
|
+
report = ActivePartition.audit
|
|
157
|
+
# => { "Partition::Conversation" => 14, "Partition::Message" => 0 }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
O vía Rake task:
|
|
161
|
+
```bash
|
|
162
|
+
bundle exec rails active_partition:audit
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Limpieza (Cleanup)
|
|
166
|
+
|
|
167
|
+
Mueve los datos huérfanos a sus particiones correspondientes de forma atómica y segura.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# Ruby API (Ideal para Jobs nocturnos)
|
|
171
|
+
ActivePartition.cleanup!
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
O vía Rake task:
|
|
175
|
+
```bash
|
|
176
|
+
bundle exec rails active_partition:cleanup
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## 📊 Observabilidad
|
|
180
|
+
|
|
181
|
+
Puedes suscribirte a los eventos para enviar métricas a tu sistema de monitoreo (Datadog, Prometheus, NewRelic).
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# config/initializers/notifications.rb
|
|
185
|
+
ActiveSupport::Notifications.subscribe(/activepartition/) do |name, start, finish, id, payload|
|
|
186
|
+
duration = (finish - start) * 1000
|
|
187
|
+
|
|
188
|
+
case name
|
|
189
|
+
when "create.active_partition"
|
|
190
|
+
Rails.logger.info "📦 Partición creada: #{payload[:table]} para #{payload[:value]}"
|
|
191
|
+
when "populate.active_partition"
|
|
192
|
+
Rails.logger.info "🧹 Limpieza: #{payload[:count]} registros movidos en #{duration.round(2)}ms"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## 📄 Licencia
|
|
198
|
+
|
|
199
|
+
Este proyecto está disponible como código abierto bajo los términos de la [Licencia MIT](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActivePartition
|
|
4
|
+
# Clase base para la gestión de infraestructura de particionamiento en PostgreSQL.
|
|
5
|
+
#
|
|
6
|
+
# Proporciona una interfaz para manejar el ciclo de vida de las tablas físicas (Particiones).
|
|
7
|
+
#
|
|
8
|
+
# @abstract Hereda de esta clase para definir un recurso de partición.
|
|
9
|
+
# @example
|
|
10
|
+
# class Partition::Chat < ActivePartition::Base
|
|
11
|
+
# # Opcional: Sobrescribir la clave global
|
|
12
|
+
# self.partition_key = :region_code
|
|
13
|
+
# end
|
|
14
|
+
class Base
|
|
15
|
+
include ActiveModel::Model
|
|
16
|
+
include ActiveModel::Attributes
|
|
17
|
+
|
|
18
|
+
# Registra el atributo de partición en la subclase en el momento de la herencia.
|
|
19
|
+
def self.inherited(subclass)
|
|
20
|
+
super
|
|
21
|
+
# Intentamos definir el atributo por defecto si existe configuración global
|
|
22
|
+
key = ActivePartition.configuration&.partition_key
|
|
23
|
+
subclass.attribute key if key
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_writer :parent_table, :prefix, :default_table
|
|
28
|
+
|
|
29
|
+
# Permite inyectar una clave de partición personalizada por clase
|
|
30
|
+
attr_writer :partition_key
|
|
31
|
+
|
|
32
|
+
def parent_table
|
|
33
|
+
@parent_table ||= name.demodulize.underscore.pluralize
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Prioridad: 1. Clave de la clase, 2. Clave global
|
|
37
|
+
def partition_key
|
|
38
|
+
@partition_key ||= ActivePartition.configuration&.partition_key ||
|
|
39
|
+
raise(ActivePartition::Error, "Clave de partición no configurada.")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def partition_key=(value)
|
|
43
|
+
@partition_key = value
|
|
44
|
+
attribute value # Define el atributo en ActiveModel automáticamente
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prefix
|
|
48
|
+
@prefix ||= "#{parent_table}_#{partition_key.to_s.gsub('_id', '')}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def default_table
|
|
52
|
+
@default_table ||= "#{parent_table}_default"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def connection
|
|
56
|
+
ActiveRecord::Base.connection
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Crea físicamente una partición en PostgreSQL.
|
|
60
|
+
def create(value)
|
|
61
|
+
# Importante: Usamos el método partition_key (no la variable) para respetar overrides
|
|
62
|
+
payload = { partition_key: partition_key, value: value, table: parent_table }
|
|
63
|
+
|
|
64
|
+
ActiveSupport::Notifications.instrument("create.active_partition", payload) do
|
|
65
|
+
name = partition_name(value)
|
|
66
|
+
sql = "CREATE TABLE IF NOT EXISTS #{name} PARTITION OF #{parent_table} FOR VALUES IN ('#{value}');"
|
|
67
|
+
connection.execute(sql)
|
|
68
|
+
new(partition_key => value)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def partition_name(value)
|
|
73
|
+
sanitized = value.to_s.gsub('-', '_')
|
|
74
|
+
"#{prefix}_#{sanitized}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def exists?(value)
|
|
78
|
+
name = partition_name(value)
|
|
79
|
+
sql = <<-SQL.squish
|
|
80
|
+
SELECT 1 FROM pg_class c
|
|
81
|
+
JOIN pg_inherits i ON c.oid = i.inhrelid
|
|
82
|
+
JOIN pg_class p ON i.inhparent = p.oid
|
|
83
|
+
WHERE p.relname = '#{parent_table}' AND c.relname = '#{name}';
|
|
84
|
+
SQL
|
|
85
|
+
connection.execute(sql).any?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def find(value)
|
|
89
|
+
new(partition_key => value) if exists?(value)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Métodos de Instancia ---
|
|
94
|
+
|
|
95
|
+
def partition_id
|
|
96
|
+
# CORRECCIÓN: Usamos public_send porque ActiveModel no tiene read_attribute
|
|
97
|
+
# Esto invoca al getter generado dinámicamente (ej: .isp_id)
|
|
98
|
+
public_send(self.class.partition_key)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def partition_table_name
|
|
102
|
+
self.class.partition_name(partition_id)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def persisted?
|
|
106
|
+
self.class.exists?(partition_id)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Mueve registros desde la tabla por defecto hacia la partición atómicamente.
|
|
110
|
+
def populate_from_default(batch_size: 5000)
|
|
111
|
+
return 0 unless persisted?
|
|
112
|
+
|
|
113
|
+
parent = self.class.parent_table
|
|
114
|
+
default = self.class.default_table
|
|
115
|
+
key = self.class.partition_key
|
|
116
|
+
val = partition_id
|
|
117
|
+
|
|
118
|
+
payload = { partition_key: key, value: val, parent_table: parent }
|
|
119
|
+
|
|
120
|
+
ActiveSupport::Notifications.instrument("populate.active_partition", payload) do |notification_payload|
|
|
121
|
+
total_moved = 0
|
|
122
|
+
|
|
123
|
+
loop do
|
|
124
|
+
batch_count = 0
|
|
125
|
+
self.class.connection.transaction do
|
|
126
|
+
# Usamos el ID para paginar el borrado/insertado
|
|
127
|
+
move_sql = <<-SQL.squish
|
|
128
|
+
WITH moved_rows AS (
|
|
129
|
+
DELETE FROM #{default}
|
|
130
|
+
WHERE #{key} = '#{val}'
|
|
131
|
+
AND id IN (
|
|
132
|
+
SELECT id FROM #{default} WHERE #{key} = '#{val}' LIMIT #{batch_size}
|
|
133
|
+
)
|
|
134
|
+
RETURNING *
|
|
135
|
+
)
|
|
136
|
+
INSERT INTO #{parent} SELECT * FROM moved_rows;
|
|
137
|
+
SQL
|
|
138
|
+
|
|
139
|
+
result = self.class.connection.execute(move_sql)
|
|
140
|
+
batch_count = result.cmd_tuples
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
total_moved += batch_count
|
|
144
|
+
break if batch_count < batch_size
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
notification_payload[:count] = total_moved
|
|
148
|
+
total_moved
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def destroy
|
|
153
|
+
return false unless persisted?
|
|
154
|
+
|
|
155
|
+
p_name = partition_table_name
|
|
156
|
+
p_table = self.class.parent_table
|
|
157
|
+
|
|
158
|
+
self.class.connection.transaction do
|
|
159
|
+
self.class.connection.execute("ALTER TABLE #{p_table} DETACH PARTITION #{p_name};")
|
|
160
|
+
self.class.connection.execute("DROP TABLE IF EXISTS #{p_name};")
|
|
161
|
+
end
|
|
162
|
+
true
|
|
163
|
+
rescue ActiveRecord::StatementInvalid
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActivePartition
|
|
4
|
+
module Concerns
|
|
5
|
+
# Helpers y validaciones para controladores en arquitecturas Multi-tenant.
|
|
6
|
+
#
|
|
7
|
+
# Este módulo implementa una estrategia de extracción estricta:
|
|
8
|
+
# Solo acepta el ID de partición si viene en el Header HTTP configurado explícitamente.
|
|
9
|
+
#
|
|
10
|
+
# @example Configuración requerida
|
|
11
|
+
# # config/initializers/active_partition.rb
|
|
12
|
+
# ActivePartition.configure do |config|
|
|
13
|
+
# config.partition_key = :isp_id
|
|
14
|
+
# config.header_name = 'X-Tenant-ID' # <--- Obligatorio
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example Uso en el controlador
|
|
18
|
+
# class ApiController < ActionController::API
|
|
19
|
+
# include ActivePartition::Concerns::Controller
|
|
20
|
+
# before_action :require_partition_key!
|
|
21
|
+
# end
|
|
22
|
+
module Controller
|
|
23
|
+
extend ActiveSupport::Concern
|
|
24
|
+
|
|
25
|
+
included do
|
|
26
|
+
# Expone el método a las vistas de Rails (erb, jbuilder, etc.)
|
|
27
|
+
helper_method :current_partition_id if respond_to?(:helper_method)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Devuelve el valor del ID de partición actual extraído exclusivamente de los Headers.
|
|
31
|
+
#
|
|
32
|
+
# Utiliza únicamente el nombre de header definido en +ActivePartition.configuration.header_name+.
|
|
33
|
+
# No realiza inferencias ni busca en parámetros de la URL.
|
|
34
|
+
#
|
|
35
|
+
# @return [String, nil] El valor del header o nil si no está presente o configurado.
|
|
36
|
+
def current_partition_id
|
|
37
|
+
return @current_partition_id if defined?(@current_partition_id)
|
|
38
|
+
|
|
39
|
+
header_key = ActivePartition.configuration.header_name
|
|
40
|
+
|
|
41
|
+
# Si no se configuró un nombre de header, no podemos buscar nada.
|
|
42
|
+
return @current_partition_id = nil unless header_key.present?
|
|
43
|
+
|
|
44
|
+
@current_partition_id = request.headers[header_key]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Filtro (before_action) para detener la ejecución si el ID de partición no está presente.
|
|
48
|
+
#
|
|
49
|
+
# Retorna un error 400 Bad Request si el header falta.
|
|
50
|
+
#
|
|
51
|
+
# @return [void]
|
|
52
|
+
def require_partition_key!
|
|
53
|
+
return if current_partition_id.present?
|
|
54
|
+
|
|
55
|
+
header_key = ActivePartition.configuration.header_name
|
|
56
|
+
|
|
57
|
+
# Mensaje de error detallado dependiendo de si es un error de configuración o de petición
|
|
58
|
+
error_message = if header_key.blank?
|
|
59
|
+
"Server Configuration Error: 'header_name' is not configured in ActivePartition."
|
|
60
|
+
else
|
|
61
|
+
"Missing required header: '#{header_key}'"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
render json: {
|
|
65
|
+
error: "Partitioning Error",
|
|
66
|
+
message: error_message
|
|
67
|
+
}, status: :bad_request
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActivePartition
|
|
4
|
+
module Concerns
|
|
5
|
+
# Módulo de infraestructura para modelos de negocio (ApplicationRecord).
|
|
6
|
+
#
|
|
7
|
+
# Este concern es el encargado de adaptar el modelo de Rails para trabajar con
|
|
8
|
+
# la estructura de tablas particionadas de PostgreSQL. Su función crítica es
|
|
9
|
+
# configurar las **Primary Keys Compuestas (CPK)** requeridas por Rails 7.1+.
|
|
10
|
+
#
|
|
11
|
+
# ### Estrategia de Resolución de Claves
|
|
12
|
+
# El módulo determina qué columna usar como clave de partición siguiendo este orden de prioridad:
|
|
13
|
+
#
|
|
14
|
+
# 1. **Explícita:** Definida manualmente con `partitioned_by :key` en el modelo.
|
|
15
|
+
# 2. **Inferencia (Convención):** Busca si existe una clase de infraestructura asociada
|
|
16
|
+
# (ej: para `Conversation` busca `Partition::Conversation`) y utiliza su configuración.
|
|
17
|
+
# 3. **Global:** Utiliza la clave definida en `ActivePartition.configure`.
|
|
18
|
+
#
|
|
19
|
+
# @example Modo Automático (Inferencia)
|
|
20
|
+
# # Si Partition::LegacyChat tiene `self.partition_key = :region_id`
|
|
21
|
+
# class LegacyChat < ApplicationRecord
|
|
22
|
+
# include ActivePartition::Concerns::Partitioned
|
|
23
|
+
# # Automáticamente configura PK: [:id, :region_id]
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example Modo Manual (Override)
|
|
27
|
+
# class Log < ApplicationRecord
|
|
28
|
+
# include ActivePartition::Concerns::Partitioned
|
|
29
|
+
# partitioned_by :custom_id
|
|
30
|
+
# end
|
|
31
|
+
module Partitioned
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
included do
|
|
35
|
+
# Punto de entrada para inclusión directa.
|
|
36
|
+
# 'self' es la clase que incluye el módulo.
|
|
37
|
+
ActivePartition::Concerns::Partitioned.configure_model(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
# Hook de Ruby disparado al heredar.
|
|
42
|
+
# Permite incluir el módulo en ApplicationRecord y que la configuración
|
|
43
|
+
# se aplique automáticamente a cada subclase en el momento de su definición.
|
|
44
|
+
#
|
|
45
|
+
# @param subclass [Class] La clase que está heredando.
|
|
46
|
+
def inherited(subclass)
|
|
47
|
+
super
|
|
48
|
+
ActivePartition::Concerns::Partitioned.configure_model(subclass)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Define manualmente la clave de partición, ignorando la inferencia y la config global.
|
|
52
|
+
#
|
|
53
|
+
# @param key [Symbol] Nombre de la columna de partición.
|
|
54
|
+
def partitioned_by(key)
|
|
55
|
+
ActivePartition::Concerns::Partitioned.apply_configuration(self, key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# --- Métodos de Utilería (Privados de la Gema) ---
|
|
60
|
+
|
|
61
|
+
# Orquesta la configuración del modelo.
|
|
62
|
+
# @api private
|
|
63
|
+
def self.configure_model(klass)
|
|
64
|
+
# Ignoramos clases abstractas para evitar errores de tabla inexistente.
|
|
65
|
+
return if klass.respond_to?(:abstract_class?) && klass.abstract_class?
|
|
66
|
+
|
|
67
|
+
# Resolvemos la clave correcta según la prioridad establecida.
|
|
68
|
+
key_to_use = resolve_partition_key(klass)
|
|
69
|
+
|
|
70
|
+
apply_configuration(klass, key_to_use) if key_to_use.present?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Determina la clave de partición inspeccionando la infraestructura y la configuración.
|
|
74
|
+
#
|
|
75
|
+
# @param klass [Class] El modelo de negocio a inspeccionar.
|
|
76
|
+
# @return [Symbol, nil] La clave de partición encontrada o nil.
|
|
77
|
+
# @api private
|
|
78
|
+
def self.resolve_partition_key(klass)
|
|
79
|
+
# 1. Inferencia por Convención:
|
|
80
|
+
# Buscamos la clase "Partition::NombreDelModelo".
|
|
81
|
+
# Ej: Conversation -> Partition::Conversation
|
|
82
|
+
infra_class_name = "Partition::#{klass.name}"
|
|
83
|
+
|
|
84
|
+
# safe_constantize (ActiveSupport) devuelve la clase si existe, nil si no.
|
|
85
|
+
# No lanza excepciones si la constante no está definida.
|
|
86
|
+
infra_class = infra_class_name.safe_constantize
|
|
87
|
+
|
|
88
|
+
if infra_class && infra_class.respond_to?(:partition_key)
|
|
89
|
+
# Si existe el modelo de infraestructura, confiamos en su configuración.
|
|
90
|
+
return infra_class.partition_key
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# 2. Configuración Global (Fallback):
|
|
94
|
+
ActivePartition.configuration&.partition_key
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Aplica la configuración de Primary Key y Scopes al modelo.
|
|
98
|
+
#
|
|
99
|
+
# @param klass [Class] El modelo a configurar.
|
|
100
|
+
# @param key [Symbol] La clave de partición.
|
|
101
|
+
# @api private
|
|
102
|
+
def self.apply_configuration(klass, key)
|
|
103
|
+
# Idempotencia:
|
|
104
|
+
# Si la clase ya tiene la PK compuesta correcta, no hacemos nada.
|
|
105
|
+
# Esto previene conflictos si se llama múltiples veces.
|
|
106
|
+
return if klass.primary_key.is_a?(Array) && klass.primary_key.include?(key.to_s)
|
|
107
|
+
|
|
108
|
+
# A. Configuración de CPK (Composite Primary Keys) - Rails 7.1+
|
|
109
|
+
# Le dice a Rails que la identidad única es (id + partition_key).
|
|
110
|
+
klass.primary_key = [:id, key]
|
|
111
|
+
|
|
112
|
+
# B. Scope de Conveniencia
|
|
113
|
+
# Permite usar Model.for_partition("uuid")
|
|
114
|
+
klass.scope :for_partition, ->(value) { where(key => value) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActivePartition
|
|
4
|
+
class Configuration
|
|
5
|
+
# @return [Symbol, nil] Columna de base de datos (ej: :isp_id)
|
|
6
|
+
attr_accessor :partition_key
|
|
7
|
+
|
|
8
|
+
# @return [String, nil] Nombre del Header HTTP personalizado (ej: 'X-Tenant-ID')
|
|
9
|
+
attr_accessor :header_name
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@partition_key = nil
|
|
13
|
+
@header_name = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def valid?
|
|
17
|
+
!partition_key.nil?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
attr_accessor :configuration
|
|
23
|
+
|
|
24
|
+
def configure
|
|
25
|
+
self.configuration ||= Configuration.new
|
|
26
|
+
yield(configuration)
|
|
27
|
+
|
|
28
|
+
unless configuration.valid?
|
|
29
|
+
raise ActivePartition::Error, "Debe configurar un 'partition_key' en el inicializador de ActivePartition."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module ActivePartition
|
|
6
|
+
# Railtie encargado de integrar ActivePartition en el ciclo de vida de Rails.
|
|
7
|
+
#
|
|
8
|
+
# Su responsabilidad principal es inyectar los componentes de la gema (Tareas Rake,
|
|
9
|
+
# Helpers de Migración) en la aplicación anfitriona durante el proceso de arranque.
|
|
10
|
+
class Railtie < Rails::Railtie
|
|
11
|
+
# Bloque para cargar tareas Rake.
|
|
12
|
+
# Debe estar definido DENTRO de la clase Railtie para tener acceso al método +rake_tasks+.
|
|
13
|
+
#
|
|
14
|
+
# Carga las tareas de mantenimiento (audit, cleanup) para que estén
|
|
15
|
+
# disponibles mediante el comando `rails`.
|
|
16
|
+
rake_tasks do
|
|
17
|
+
# Usamos expand_path para asegurar que la ruta sea absoluta respecto a la gema,
|
|
18
|
+
# evitando errores de "file not found" dependiendo de dónde se ejecute el comando.
|
|
19
|
+
load File.expand_path("tasks/maintenance.rake", __dir__)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Inicializador que se ejecuta cuando Rails carga los componentes de ActiveRecord.
|
|
23
|
+
#
|
|
24
|
+
# Inyecta los helpers de migración (como +create_partitioned_table+) directamente
|
|
25
|
+
# en el adaptador de PostgreSQL para extender el DSL de las migraciones.
|
|
26
|
+
initializer "active_partition.insert_schema_statements" do
|
|
27
|
+
ActiveSupport.on_load(:active_record) do
|
|
28
|
+
require "activepartition/schema/statements"
|
|
29
|
+
|
|
30
|
+
# Validación de seguridad:
|
|
31
|
+
# Solo inyectamos el módulo si el adaptador configurado es efectivamente PostgreSQL.
|
|
32
|
+
# Esto previene errores si la gema se instala en proyectos con MySQL o SQLite.
|
|
33
|
+
if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) && connection_db_config.adapter == "postgresql"
|
|
34
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include ActivePartition::Schema::Statements
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActivePartition
|
|
4
|
+
# Clase encargada de validar que el entorno y la configuración sean aptos
|
|
5
|
+
# para el funcionamiento de ActivePartition.
|
|
6
|
+
class SafetyGuard
|
|
7
|
+
# Versión mínima de PostgreSQL soportada para particionamiento nativo estable.
|
|
8
|
+
MIN_POSTGRES_VERSION = 13.0
|
|
9
|
+
|
|
10
|
+
# Ejecuta todas las validaciones de seguridad.
|
|
11
|
+
# @raise [ActivePartition::Error] Si alguna validación falla.
|
|
12
|
+
# @return [void]
|
|
13
|
+
def self.validate!
|
|
14
|
+
check_configuration!
|
|
15
|
+
return unless defined?(Rails) && Rails.env.to_s != 'test'
|
|
16
|
+
|
|
17
|
+
check_database_adapter!
|
|
18
|
+
check_postgres_version!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Verifica que la clave de partición esté presente.
|
|
24
|
+
def self.check_configuration!
|
|
25
|
+
if ActivePartition.configuration.partition_key.nil?
|
|
26
|
+
raise ActivePartition::Error, "Falta configuración: 'partition_key' no ha sido definido."
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Asegura que se esté utilizando PostgreSQL.
|
|
31
|
+
def self.check_database_adapter!
|
|
32
|
+
adapter = ActiveRecord::Base.connection_db_config.adapter
|
|
33
|
+
unless adapter == "postgresql"
|
|
34
|
+
raise ActivePartition::Error, "Adaptador incompatible: ActivePartition solo soporta PostgreSQL (usando: #{adapter})."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Verifica la versión de PostgreSQL mediante una consulta directa al motor.
|
|
39
|
+
def self.check_postgres_version!
|
|
40
|
+
version = ActiveRecord::Base.connection.select_value("SHOW server_version").to_f
|
|
41
|
+
if version < MIN_POSTGRES_VERSION
|
|
42
|
+
raise ActivePartition::Error, "Versión de PostgreSQL insuficiente: Se requiere v#{MIN_POSTGRES_VERSION}+ (detectada: #{version})."
|
|
43
|
+
end
|
|
44
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
45
|
+
# Si no hay conexión aún, se posterga la validación
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActivePartition
|
|
4
|
+
module Schema
|
|
5
|
+
# Proporciona métodos adicionales para las migraciones de ActiveRecord
|
|
6
|
+
# enfocados en la automatización del particionamiento nativo de PostgreSQL.
|
|
7
|
+
module Statements
|
|
8
|
+
# Crea una tabla padre particionada por lista y genera automáticamente
|
|
9
|
+
# su partición por defecto (DEFAULT).
|
|
10
|
+
#
|
|
11
|
+
# CORRECCIÓN IMPORTANTE: Configura una Primary Key Compuesta (id + partition_key)
|
|
12
|
+
# para cumplir con los requisitos de unicidad de PostgreSQL en tablas particionadas.
|
|
13
|
+
#
|
|
14
|
+
# @param table_name [Symbol, String] El nombre de la tabla a crear.
|
|
15
|
+
# @param options [Hash] Opciones estándar + :partition_key opcional.
|
|
16
|
+
# @yield [t] Bloque para definir las columnas de la tabla.
|
|
17
|
+
def create_partitioned_table(table_name, **options, &block)
|
|
18
|
+
# Prioridad: 1. Opción pasada al método, 2. Configuración global
|
|
19
|
+
key = options.delete(:partition_key) || ActivePartition.configuration&.partition_key
|
|
20
|
+
|
|
21
|
+
unless key
|
|
22
|
+
raise ActivePartition::Error, "Debe configurar 'partition_key' globalmente o pasarlo como opción."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# --- LÓGICA DE PRIMARY KEY COMPUESTA ---
|
|
26
|
+
# 1. Desactivamos la creación automática del ID simple, ya que Postgres fallaría.
|
|
27
|
+
options[:id] = false
|
|
28
|
+
|
|
29
|
+
# 2. Definimos explícitamente que la PK está formada por el ID y la CLAVE DE PARTICIÓN.
|
|
30
|
+
# Esto genera en SQL: PRIMARY KEY (id, isp_id)
|
|
31
|
+
options[:primary_key] = [:id, key]
|
|
32
|
+
|
|
33
|
+
# 3. Inyectamos la estrategia de particionamiento
|
|
34
|
+
options[:options] = "PARTITION BY LIST (#{key})"
|
|
35
|
+
|
|
36
|
+
# Crear la tabla padre
|
|
37
|
+
create_table(table_name, **options) do |t|
|
|
38
|
+
# A. Como desactivamos id: false, debemos crear la columna ID manualmente.
|
|
39
|
+
# Usamos gen_random_uuid() para que sea autogenerado.
|
|
40
|
+
t.uuid :id, null: false, default: -> { "gen_random_uuid()" }
|
|
41
|
+
|
|
42
|
+
# B. Ejecutamos las definiciones del usuario
|
|
43
|
+
block.call(t)
|
|
44
|
+
|
|
45
|
+
# C. Inyección automática del atributo de partición si el usuario no lo definió.
|
|
46
|
+
# Nota: Si el usuario ya puso `t.uuid :isp_id` en su migración, esta línea la salta.
|
|
47
|
+
unless t.columns.any? { |c| c.name == key.to_s }
|
|
48
|
+
t.column key, :string, null: false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Crear la partición DEFAULT
|
|
53
|
+
default_name = "#{table_name}_default"
|
|
54
|
+
execute <<-SQL.squish
|
|
55
|
+
CREATE TABLE IF NOT EXISTS #{default_name}
|
|
56
|
+
PARTITION OF #{table_name} DEFAULT;
|
|
57
|
+
SQL
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Elimina una tabla particionada y su tabla por defecto asociada.
|
|
61
|
+
def drop_partitioned_table(table_name)
|
|
62
|
+
drop_table(table_name, cascade: true)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# lib/activepartition/tasks/maintenance.rake
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
namespace :active_partition do
|
|
5
|
+
desc "Audita todas las tablas DEFAULT definidas en ActivePartition"
|
|
6
|
+
task audit: :environment do
|
|
7
|
+
ActivePartition.audit
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Limpia registros huérfanos moviéndolos a sus particiones"
|
|
11
|
+
task cleanup: :environment do
|
|
12
|
+
ActivePartition.cleanup!
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_model"
|
|
5
|
+
require "active_support/all"
|
|
6
|
+
|
|
7
|
+
# Carga de la versión y componentes internos
|
|
8
|
+
require_relative "activepartition/version"
|
|
9
|
+
require_relative "activepartition/configuration"
|
|
10
|
+
require_relative "activepartition/safety_guard"
|
|
11
|
+
require_relative "activepartition/base"
|
|
12
|
+
require_relative "activepartition/concerns/partitioned"
|
|
13
|
+
require_relative "activepartition/concerns/controller"
|
|
14
|
+
|
|
15
|
+
# Integración con Ruby on Rails
|
|
16
|
+
require_relative "activepartition/railtie" if defined?(Rails)
|
|
17
|
+
|
|
18
|
+
# ActivePartition es el punto de entrada principal para la gestión de particionamiento
|
|
19
|
+
# en PostgreSQL dentro de aplicaciones Rails.
|
|
20
|
+
#
|
|
21
|
+
# Actúa como una **Fachada** que centraliza:
|
|
22
|
+
# 1. Configuración de la gema.
|
|
23
|
+
# 2. Orquestación del ciclo de vida de los tenants (Crear/Borrar particiones).
|
|
24
|
+
# 3. Mantenimiento y limpieza de datos huérfanos.
|
|
25
|
+
module ActivePartition
|
|
26
|
+
# Error base para todas las excepciones de la gema.
|
|
27
|
+
class Error < StandardError; end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# @return [ActivePartition::Configuration] El objeto de configuración global.
|
|
31
|
+
attr_accessor :configuration
|
|
32
|
+
|
|
33
|
+
# Configura la gema mediante un bloque e inicializa las validaciones de seguridad.
|
|
34
|
+
#
|
|
35
|
+
# @yieldparam [ActivePartition::Configuration] config
|
|
36
|
+
# @return [void]
|
|
37
|
+
def configure
|
|
38
|
+
self.configuration ||= Configuration.new
|
|
39
|
+
yield(configuration)
|
|
40
|
+
SafetyGuard.validate!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# =========================================================================
|
|
44
|
+
# GRUPO 1: ORQUESTACIÓN DE TENANTS (Ciclo de Vida)
|
|
45
|
+
# =========================================================================
|
|
46
|
+
|
|
47
|
+
# Crea las particiones físicas para un tenant específico en TODOS los modelos registrados.
|
|
48
|
+
#
|
|
49
|
+
# Este método es **idempotente**: verifica si la partición existe antes de intentar crearla,
|
|
50
|
+
# evitando errores de SQL.
|
|
51
|
+
#
|
|
52
|
+
# @param partition_id [String, Integer] El ID del tenant (ej: el UUID del ISP).
|
|
53
|
+
# @return [void]
|
|
54
|
+
def create!(partition_id)
|
|
55
|
+
ensure_models_loaded!
|
|
56
|
+
|
|
57
|
+
log_info "CREATE", "Iniciando aprovisionamiento para ID: #{partition_id}"
|
|
58
|
+
|
|
59
|
+
partitionable_models.each do |model|
|
|
60
|
+
if model.exists?(partition_id)
|
|
61
|
+
log_info "SKIP", "#{model.name}: La partición ya existe."
|
|
62
|
+
else
|
|
63
|
+
model.create(partition_id)
|
|
64
|
+
log_info "OK", "#{model.name}: Partición creada exitosamente."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Elimina (DETACH + DROP) las particiones físicas de un tenant en TODOS los modelos.
|
|
70
|
+
#
|
|
71
|
+
# @warning Esta acción es destructiva e irreversible. Borra los datos físicos.
|
|
72
|
+
#
|
|
73
|
+
# @param partition_id [String, Integer] El ID del tenant a eliminar.
|
|
74
|
+
# @return [void]
|
|
75
|
+
def destroy!(partition_id)
|
|
76
|
+
ensure_models_loaded!
|
|
77
|
+
|
|
78
|
+
log_info "DESTROY", "Eliminando infraestructura para ID: #{partition_id}"
|
|
79
|
+
|
|
80
|
+
partitionable_models.each do |model|
|
|
81
|
+
if model.exists?(partition_id)
|
|
82
|
+
partition = model.find(partition_id)
|
|
83
|
+
if partition.destroy
|
|
84
|
+
log_info "DROP", "#{model.name}: Partición eliminada."
|
|
85
|
+
else
|
|
86
|
+
log_error "FAIL", "#{model.name}: No se pudo eliminar la partición."
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
log_info "SKIP", "#{model.name}: No existe partición para borrar."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# =========================================================================
|
|
95
|
+
# GRUPO 2: MANTENIMIENTO Y OPS (Audit & Cleanup)
|
|
96
|
+
# =========================================================================
|
|
97
|
+
|
|
98
|
+
# Audita todas las tablas DEFAULT buscando registros "huérfanos".
|
|
99
|
+
#
|
|
100
|
+
# Un registro huérfano es aquel que se insertó en la tabla padre pero, al no existir
|
|
101
|
+
# su partición correspondiente en ese momento, cayó en la tabla _default.
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash{String => Integer}] Mapa con el nombre del modelo y la cantidad de registros huérfanos.
|
|
104
|
+
# @example
|
|
105
|
+
# ActivePartition.audit
|
|
106
|
+
# # => { "Partition::Message" => 150, "Partition::Log" => 0 }
|
|
107
|
+
def audit
|
|
108
|
+
ensure_models_loaded!
|
|
109
|
+
report = {}
|
|
110
|
+
|
|
111
|
+
log_info "AUDIT", "Iniciando auditoría de tablas DEFAULT..."
|
|
112
|
+
|
|
113
|
+
partitionable_models.each do |model|
|
|
114
|
+
count = count_default_rows(model)
|
|
115
|
+
report[model.name] = count
|
|
116
|
+
|
|
117
|
+
if count > 0
|
|
118
|
+
log_warn "ALERTA", "#{model.name}: #{count} registros huérfanos encontrados."
|
|
119
|
+
else
|
|
120
|
+
log_info "OK", "#{model.name}: Tabla default limpia."
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
report
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Realiza una limpieza global moviendo registros huérfanos a sus particiones correctas.
|
|
128
|
+
#
|
|
129
|
+
# 1. Identifica qué IDs de tenant tienen datos en las tablas DEFAULT.
|
|
130
|
+
# 2. Verifica si ya existe la partición física para esos IDs.
|
|
131
|
+
# 3. Mueve los datos atómicamente.
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
def cleanup!
|
|
135
|
+
ensure_models_loaded!
|
|
136
|
+
key = configuration.partition_key
|
|
137
|
+
|
|
138
|
+
log_info "CLEANUP", "Iniciando proceso de limpieza global..."
|
|
139
|
+
|
|
140
|
+
partitionable_models.each do |model|
|
|
141
|
+
# Obtenemos los IDs únicos presentes en la tabla default
|
|
142
|
+
orphan_ids = fetch_orphan_ids(model, key)
|
|
143
|
+
|
|
144
|
+
if orphan_ids.empty?
|
|
145
|
+
log_info "OK", "#{model.name}: Sin datos huérfanos."
|
|
146
|
+
next
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
log_warn "FIX", "#{model.name}: Procesando #{orphan_ids.count} tenants con datos huérfanos."
|
|
150
|
+
|
|
151
|
+
orphan_ids.each do |id|
|
|
152
|
+
partition = model.find(id)
|
|
153
|
+
|
|
154
|
+
if partition
|
|
155
|
+
moved = partition.populate_from_default
|
|
156
|
+
log_info "MOVE", " -> ID #{id}: #{moved} registros recuperados."
|
|
157
|
+
else
|
|
158
|
+
log_error "ERROR", " -> ID #{id}: La partición física no existe. Cree el tenant primero."
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Encuentra todas las clases cargadas que heredan de ActivePartition::Base.
|
|
167
|
+
# @return [Array<Class>]
|
|
168
|
+
def partitionable_models
|
|
169
|
+
ObjectSpace.each_object(Class).select { |klass| klass < ActivePartition::Base }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Cuenta registros en la tabla default de un modelo de infraestructura.
|
|
173
|
+
# @return [Integer]
|
|
174
|
+
def count_default_rows(model)
|
|
175
|
+
model.connection.select_value("SELECT count(*) FROM #{model.default_table}").to_i
|
|
176
|
+
rescue ActiveRecord::StatementInvalid
|
|
177
|
+
0
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Obtiene los IDs de partición distintos que existen en la tabla default.
|
|
181
|
+
# @return [Array<String>]
|
|
182
|
+
def fetch_orphan_ids(model, key)
|
|
183
|
+
sql = "SELECT DISTINCT #{key} FROM #{model.default_table} WHERE #{key} IS NOT NULL"
|
|
184
|
+
model.connection.execute(sql).map { |r| r[key.to_s] }
|
|
185
|
+
rescue ActiveRecord::StatementInvalid
|
|
186
|
+
[]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Asegura que Rails haya cargado los modelos de infraestructura.
|
|
190
|
+
# Vital en modo Development donde la carga es perezosa (Lazy Loading).
|
|
191
|
+
def ensure_models_loaded!
|
|
192
|
+
return unless defined?(Rails)
|
|
193
|
+
|
|
194
|
+
partition_dir = Rails.root.join("app/models/partition")
|
|
195
|
+
return unless Dir.exist?(partition_dir)
|
|
196
|
+
|
|
197
|
+
# Forzamos la carga de todos los archivos en app/models/partition/
|
|
198
|
+
Dir[partition_dir.join("**/*.rb")].each { |file| require_dependency file }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Helpers de Logging
|
|
202
|
+
def log_info(tag, msg) = logger&.info(format_log(tag, msg))
|
|
203
|
+
def log_warn(tag, msg) = logger&.warn(format_log(tag, msg))
|
|
204
|
+
def log_error(tag, msg) = logger&.error(format_log(tag, msg))
|
|
205
|
+
|
|
206
|
+
def format_log(tag, msg)
|
|
207
|
+
"[ActivePartition] [#{tag}] #{msg}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def logger
|
|
211
|
+
defined?(Rails) ? Rails.logger : Logger.new($stdout)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tenant_partition
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- gabriel
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-01-27 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: activemodel
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '7.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '7.1'
|
|
41
|
+
description: Framework de infraestructura para Rails 7.1+ que automatiza el particionamiento
|
|
42
|
+
nativo (List Partitioning). Incluye soporte para Composite Primary Keys, orquestación
|
|
43
|
+
de tenants y migraciones zero-downtime.
|
|
44
|
+
email:
|
|
45
|
+
- gedera@wispro.co
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE.txt
|
|
52
|
+
- README.md
|
|
53
|
+
- Rakefile
|
|
54
|
+
- lib/activepartition.rb
|
|
55
|
+
- lib/activepartition/base.rb
|
|
56
|
+
- lib/activepartition/concerns/controller.rb
|
|
57
|
+
- lib/activepartition/concerns/partitioned.rb
|
|
58
|
+
- lib/activepartition/configuration.rb
|
|
59
|
+
- lib/activepartition/railtie.rb
|
|
60
|
+
- lib/activepartition/safety_guard.rb
|
|
61
|
+
- lib/activepartition/schema/statements.rb
|
|
62
|
+
- lib/activepartition/tasks/maintenance.rake
|
|
63
|
+
- lib/activepartition/version.rb
|
|
64
|
+
- sig/activepartition.rbs
|
|
65
|
+
homepage: https://github.com/gedera/activepartition
|
|
66
|
+
licenses:
|
|
67
|
+
- MIT
|
|
68
|
+
metadata:
|
|
69
|
+
source_code_uri: https://github.com/gedera/tenant_partition
|
|
70
|
+
changelog_uri: https://github.com/gedera/tenant_partition/blob/main/CHANGELOG.md
|
|
71
|
+
post_install_message:
|
|
72
|
+
rdoc_options: []
|
|
73
|
+
require_paths:
|
|
74
|
+
- lib
|
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: 3.2.0
|
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '0'
|
|
85
|
+
requirements: []
|
|
86
|
+
rubygems_version: 3.4.19
|
|
87
|
+
signing_key:
|
|
88
|
+
specification_version: 4
|
|
89
|
+
summary: Gestión de particiones PostgreSQL al estilo Rails.
|
|
90
|
+
test_files: []
|