tenant_partition 0.1.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +15 -4
- data/README.md +112 -124
- data/lib/generators/tenant_partition/api_controller_generator.rb +1 -0
- data/lib/generators/tenant_partition/templates/tenant_partitions_controller.rb.erb +13 -2
- data/lib/tenant_partition/concerns/data_mover.rb +17 -30
- data/lib/tenant_partition/concerns/partitioned.rb +116 -41
- data/lib/tenant_partition/maintenance.rb +28 -31
- data/lib/tenant_partition/version.rb +1 -1
- data/lib/tenant_partition.rb +66 -43
- metadata +2 -15
- data/lib/tenant_partition/base.rb +0 -138
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f9ccf1323d98dd3c78356387de4666127584d7539098e6501d326da1d107fa0
|
|
4
|
+
data.tar.gz: 05e849e009ee93921e6de9b8f207a6ae91781e2b177295d2ae23def5df5e07b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 368ec2f0cfc8b312ca5b5ac61eb7ace18da414faae68846e6353e7058cb5079f04777a0c6418fdd03ddd66da90df188f05d23c43dce8292a556a13d30ca48da1
|
|
7
|
+
data.tar.gz: ecab7244f3a6e106c96a27954b2df753fd1ac0143d67423b35b653c7816dfc973b946b538f1b6a6afb6dae7b0d44ff358b607bb6d394b86e17e0b9704ef86034
|
data/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
4
|
-
### Changed
|
|
5
|
-
-
|
|
6
|
-
-
|
|
3
|
+
## [0.2.0] - 2026-02-13
|
|
4
|
+
### Changed (Breaking Changes)
|
|
5
|
+
- **Arquitectura:** Se eliminó la clase `TenantPartition::Base` y la necesidad de crear modelos de infraestructura en el namespace `Partition::`.
|
|
6
|
+
- **Integración:** Ahora se utiliza un concern `TenantPartition::Concerns::Partitioned` y la macro `partition_table` directamente en los modelos de dominio.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Macro `partition_table` para activar particionamiento de forma declarativa (Opt-In).
|
|
10
|
+
- Soporte para claves primarias compuestas (Composite Primary Keys) nativas de Rails 7.1+.
|
|
11
|
+
- Nuevo sistema de "Registry" interno para el descubrimiento de modelos particionados (reemplaza a `ObjectSpace`).
|
|
12
|
+
- Métodos de clase `create_partition`, `drop_partition` y `partition_table_exists?` inyectados directamente en el modelo.
|
|
13
|
+
- Documentación actualizada con estrategias de inclusión (Global vs Local).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Corrección de ofensas de RuboCop en métodos largos de generación SQL.
|
|
17
|
+
- Mejoras en la seguridad de tipos en la orquestación de tareas de mantenimiento.
|
|
7
18
|
|
|
8
19
|
## [0.1.2] - 2026-02-03
|
|
9
20
|
### Added
|
data/README.md
CHANGED
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
# TenantPartition
|
|
2
2
|
|
|
3
|
-
**TenantPartition** es
|
|
3
|
+
**TenantPartition** es una solución robusta y "Rails-native" para implementar **Particionamiento Declarativo (List Partitioning)** de PostgreSQL en aplicaciones Ruby on Rails.
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
5
|
+
A diferencia de otras soluciones que dependen de esquemas (schemas) o hackeos a la conexión de base de datos, `tenant_partition` utiliza características nativas de PostgreSQL para dividir tablas gigantes en tablas físicas más pequeñas por tenant (Cliente, ISP, Organización, etc.), manteniendo la experiencia de desarrollo de ActiveRecord estándar.
|
|
9
6
|
|
|
10
7
|
## 🚀 Características Principales
|
|
11
8
|
|
|
12
|
-
* **
|
|
13
|
-
* **
|
|
14
|
-
* **
|
|
15
|
-
* **
|
|
16
|
-
* **
|
|
9
|
+
* **API Simple (Opt-in):** Usa `partition_table` en tus modelos para activar la magia.
|
|
10
|
+
* **Soporte Nativo CPK:** Compatible con **Composite Primary Keys** de Rails 7.1+.
|
|
11
|
+
* **Sin Magic Strings:** Usa métodos explícitos (`create_partition`, `drop_partition`).
|
|
12
|
+
* **Gestión de Datos Huérfanos:** Herramientas para mover datos de la tabla "Default" a su partición correcta automáticamente.
|
|
13
|
+
* **Safety Guards:** Protección contra borrados accidentales en Producción.
|
|
14
|
+
* **Tasks de Mantenimiento:** Rake tasks integradas para auditoría y limpieza.
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
* **Ruby:** >= 3.2
|
|
21
|
-
* **Ruby on Rails:** >= 7.1
|
|
22
|
-
* **PostgreSQL:** >= 13.0 (Requerido para `gen_random_uuid()` nativo)
|
|
16
|
+
---
|
|
23
17
|
|
|
24
18
|
## 📦 Instalación
|
|
25
19
|
|
|
@@ -29,183 +23,177 @@ Agrega esto a tu `Gemfile`:
|
|
|
29
23
|
gem 'tenant_partition'
|
|
30
24
|
```
|
|
31
25
|
|
|
32
|
-
|
|
26
|
+
Y ejecuta:
|
|
33
27
|
|
|
34
28
|
```bash
|
|
35
29
|
bundle install
|
|
36
30
|
```
|
|
37
31
|
|
|
38
|
-
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## ⚙️ Configuración Inicial
|
|
39
35
|
|
|
40
|
-
Crea un inicializador
|
|
36
|
+
Crea un inicializador para definir tu clave de partición global (por ejemplo, `:isp_id`, `:account_id`, `:tenant_id`).
|
|
41
37
|
|
|
42
38
|
```ruby
|
|
39
|
+
# config/initializers/tenant_partition.rb
|
|
40
|
+
|
|
43
41
|
TenantPartition.configure do |config|
|
|
44
|
-
#
|
|
42
|
+
# Esta es la columna que actuará como discriminador global
|
|
45
43
|
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
44
|
end
|
|
50
45
|
```
|
|
51
46
|
|
|
52
|
-
|
|
47
|
+
---
|
|
53
48
|
|
|
54
|
-
|
|
49
|
+
## 🏗 Estrategias de Uso
|
|
55
50
|
|
|
56
|
-
|
|
51
|
+
La gema utiliza un patrón "Opt-In". Incluir el módulo no altera tus modelos hasta que lo activas explícitamente. Puedes elegir la estrategia que mejor se adapte a tu proyecto:
|
|
52
|
+
|
|
53
|
+
### Opción A: Global (Recomendado)
|
|
54
|
+
Incluye el concern en `ApplicationRecord`. Esto **NO** particiona tus tablas, solo habilita la posibilidad de usar la macro `partition_table` en el futuro. Es ideal para mantener el código limpio.
|
|
57
55
|
|
|
58
56
|
```ruby
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
66
|
-
end
|
|
57
|
+
# app/models/application_record.rb
|
|
58
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
59
|
+
primary_abstract_class
|
|
60
|
+
|
|
61
|
+
# Habilita la herramienta, pero permanece inactiva por defecto.
|
|
62
|
+
include TenantPartition::Concerns::Partitioned
|
|
67
63
|
end
|
|
68
64
|
```
|
|
69
65
|
|
|
70
|
-
###
|
|
71
|
-
|
|
72
|
-
Define modelos que hereden de `TenantPartition::Base`. Estos modelos son responsables de las operaciones DDL (Create/Drop tables). Por convención, se recomienda usar el namespace `Partition::`.
|
|
66
|
+
### Opción B: Local (A la carta)
|
|
67
|
+
Si prefieres no tocar `ApplicationRecord` o estás en un sistema legacy, puedes incluir el concern solo en los modelos específicos.
|
|
73
68
|
|
|
74
69
|
```ruby
|
|
75
|
-
# app/models/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
end
|
|
70
|
+
# app/models/conversation.rb
|
|
71
|
+
class Conversation < ApplicationRecord
|
|
72
|
+
include TenantPartition::Concerns::Partitioned
|
|
73
|
+
partition_table # Activación inmediata
|
|
80
74
|
end
|
|
81
75
|
```
|
|
82
76
|
|
|
83
|
-
|
|
77
|
+
---
|
|
84
78
|
|
|
85
|
-
|
|
79
|
+
## 📖 Referencia de Macros y Métodos
|
|
86
80
|
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
Una vez que incluyes `TenantPartition::Concerns::Partitioned` en tu clase, obtienes acceso a las siguientes herramientas:
|
|
82
|
+
|
|
83
|
+
### 1. La Macro de Activación: `partition_table`
|
|
84
|
+
|
|
85
|
+
Es el interruptor de encendido. Debe llamarse al inicio de la definición del modelo.
|
|
89
86
|
|
|
90
87
|
```ruby
|
|
91
|
-
# app/models/conversation.rb
|
|
92
88
|
class Conversation < ApplicationRecord
|
|
93
|
-
|
|
89
|
+
# Uso estándar (usa la key configurada globalmente, ej: :isp_id)
|
|
90
|
+
partition_table
|
|
94
91
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
92
|
+
# Uso personalizado (para modelos con keys únicas, ej: :year)
|
|
93
|
+
# partition_table key: :year
|
|
97
94
|
end
|
|
98
95
|
```
|
|
99
96
|
|
|
100
|
-
|
|
97
|
+
**¿Qué hace esta macro internamente?**
|
|
98
|
+
1. Configura la **Primary Key Compuesta** (`[:id, :partition_key]`).
|
|
99
|
+
2. Registra el modelo en el sistema de mantenimiento de la gema.
|
|
100
|
+
3. Inyecta los métodos de gestión de infraestructura (ver abajo).
|
|
101
|
+
4. Agrega el scope `for_partition(value)`.
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
### 2. Métodos de Gestión de Infraestructura (Class Methods)
|
|
103
104
|
|
|
104
|
-
**
|
|
105
|
-
Ideal para usar en tu `RegistrationService` o `AfterCommit` de la creación del tenant.
|
|
105
|
+
Estos métodos se inyectan en tu modelo **solo después** de llamar a `partition_table`. Úsalos para gestionar el ciclo de vida de las tablas físicas.
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
| Método | Descripción | Ejemplo |
|
|
108
|
+
| :--- | :--- | :--- |
|
|
109
|
+
| `create_partition(val)` | Crea la tabla física en Postgres (`CREATE TABLE ... PARTITION OF ...`). | `Conversation.create_partition(100)` |
|
|
110
|
+
| `drop_partition(val)` | Elimina la tabla física y sus datos (`DROP TABLE ...`). | `Conversation.drop_partition(100)` |
|
|
111
|
+
| `partition_table_exists?(val)` | Devuelve `true` si la tabla física existe en la BD. | `Conversation.partition_table_exists?(100)` |
|
|
112
|
+
| `partition_table_name(val)` | Devuelve el nombre real de la tabla hija. | `Conversation.partition_table_name(100)` <br> *=> "conversations_isp_100"* |
|
|
111
113
|
|
|
112
|
-
|
|
113
|
-
# Es idempotente: si alguna ya existe, la salta sin error.
|
|
114
|
-
TenantPartition.create!(isp.id)
|
|
115
|
-
end
|
|
116
|
-
```
|
|
114
|
+
---
|
|
117
115
|
|
|
118
|
-
|
|
116
|
+
## 🛠 Guía de Implementación
|
|
119
117
|
|
|
120
|
-
|
|
121
|
-
# Esta operación realiza DETACH + DROP de las tablas físicas.
|
|
122
|
-
# ¡Es destructiva e irreversible!
|
|
123
|
-
TenantPartition.destroy!(old_isp.id)
|
|
124
|
-
```
|
|
118
|
+
### 1. Migración de Base de Datos
|
|
125
119
|
|
|
126
|
-
|
|
120
|
+
PostgreSQL necesita que la tabla padre se cree con la opción `PARTITION BY LIST`.
|
|
127
121
|
|
|
128
|
-
|
|
122
|
+
```ruby
|
|
123
|
+
class CreateConversations < ActiveRecord::Migration[7.1]
|
|
124
|
+
def up
|
|
125
|
+
# 1. Crear la tabla padre particionada (id: false es importante)
|
|
126
|
+
create_table :conversations, id: false, options: "PARTITION BY LIST (isp_id)" do |t|
|
|
127
|
+
t.bigserial :id, null: false
|
|
128
|
+
t.integer :isp_id, null: false # Tu partition key
|
|
129
|
+
|
|
130
|
+
t.string :subject
|
|
131
|
+
t.timestamps
|
|
132
|
+
end
|
|
129
133
|
|
|
130
|
-
|
|
134
|
+
# 2. Definir la Primary Key Compuesta (Requerido por Postgres)
|
|
135
|
+
execute "ALTER TABLE conversations ADD PRIMARY KEY (id, isp_id);"
|
|
131
136
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
#
|
|
137
|
+
# 3. Crear tabla DEFAULT (Recomendado para evitar errores de inserción)
|
|
138
|
+
execute "CREATE TABLE conversations_default PARTITION OF conversations DEFAULT;"
|
|
139
|
+
end
|
|
140
|
+
# ...
|
|
136
141
|
```
|
|
137
142
|
|
|
138
|
-
###
|
|
143
|
+
### 2. Callbacks de Aprovisionamiento
|
|
139
144
|
|
|
140
|
-
|
|
145
|
+
Es común automatizar la creación de particiones cuando se crea un nuevo Tenant (ej. un nuevo ISP o Cliente).
|
|
141
146
|
|
|
142
147
|
```ruby
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# Valida que el request traiga el header 'X-Tenant-ID'.
|
|
147
|
-
# Devuelve 400 Bad Request si falta.
|
|
148
|
-
before_action :require_partition_key!
|
|
148
|
+
# app/models/isp.rb
|
|
149
|
+
class Isp < ApplicationRecord
|
|
150
|
+
after_create :provision_infrastructure
|
|
149
151
|
|
|
150
|
-
def
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
render json: @chats
|
|
152
|
+
def provision_infrastructure
|
|
153
|
+
# Método helper que crea las particiones en TODOS los modelos registrados
|
|
154
|
+
TenantPartition.create!(self.id)
|
|
154
155
|
end
|
|
155
156
|
end
|
|
156
157
|
```
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
TenantPartition 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`.
|
|
159
|
+
---
|
|
161
160
|
|
|
162
|
-
|
|
161
|
+
## 🧹 Mantenimiento y Datos Huérfanos
|
|
163
162
|
|
|
164
|
-
|
|
163
|
+
Si insertas datos con un `isp_id` para el cual no has creado una partición (y tienes una tabla `DEFAULT`), los datos caerán ahí.
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
report = TenantPartition.audit
|
|
169
|
-
# => { "Partition::Conversation" => 14, "Partition::Message" => 0 }
|
|
170
|
-
```
|
|
165
|
+
### Auditoría
|
|
166
|
+
Verifica si tienes datos en las tablas default:
|
|
171
167
|
|
|
172
|
-
O vía Rake task:
|
|
173
168
|
```bash
|
|
174
|
-
bundle exec
|
|
169
|
+
bundle exec rake tenant_partition:audit
|
|
170
|
+
# [TenantPartition] [AUDIT] Iniciando auditoría...
|
|
171
|
+
# [TenantPartition] [ALERTA] Conversation: 450 registros huérfanos encontrados.
|
|
175
172
|
```
|
|
176
173
|
|
|
177
174
|
### Limpieza (Cleanup)
|
|
175
|
+
Crea las particiones faltantes y mueve los datos automáticamente:
|
|
178
176
|
|
|
179
|
-
Mueve los datos huérfanos a sus particiones correspondientes de forma atómica y segura.
|
|
180
|
-
|
|
181
|
-
```ruby
|
|
182
|
-
# Ruby API (Ideal para Jobs nocturnos)
|
|
183
|
-
TenantPartition.cleanup!
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
O vía Rake task:
|
|
187
177
|
```bash
|
|
188
|
-
bundle exec
|
|
178
|
+
bundle exec rake tenant_partition:cleanup
|
|
179
|
+
# [TenantPartition] [FIX] Conversation: Procesando 2 tenants con datos huérfanos.
|
|
180
|
+
# [TenantPartition] [MOVE] -> ID 101: 450 registros recuperados.
|
|
189
181
|
```
|
|
190
182
|
|
|
191
|
-
|
|
183
|
+
---
|
|
192
184
|
|
|
193
|
-
|
|
185
|
+
## 🛡️ Producción y Seguridad
|
|
194
186
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
when "create.tenant_partition"
|
|
202
|
-
Rails.logger.info "📦 Partición creada: #{payload[:table]} para #{payload[:value]}"
|
|
203
|
-
when "populate.tenant_partition"
|
|
204
|
-
Rails.logger.info "🧹 Limpieza: #{payload[:count]} registros movidos en #{duration.round(2)}ms"
|
|
205
|
-
end
|
|
206
|
-
end
|
|
187
|
+
La gema incluye un `SafetyGuard` que impide ejecutar comandos destructivos (`drop_partition`, `destroy!`) en entorno de producción a menos que se fuerce explícitamente.
|
|
188
|
+
|
|
189
|
+
Para ejecutar tareas destructivas en producción, debes setear la variable de entorno:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
DISABLE_TENANT_PARTITION_GUARD=true bundle exec rake tenant_partition:destroy_tenant[123]
|
|
207
193
|
```
|
|
208
194
|
|
|
209
|
-
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## License
|
|
210
198
|
|
|
211
|
-
|
|
199
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -61,6 +61,7 @@ module TenantPartition
|
|
|
61
61
|
# Definición de rutas explícitas apuntando al controlador generado
|
|
62
62
|
route "post '#{folder_name}/tenant_partitions', to: '#{folder_name}/tenant_partitions#create'"
|
|
63
63
|
route "delete '#{folder_name}/tenant_partitions/:id', to: '#{folder_name}/tenant_partitions#destroy'"
|
|
64
|
+
route "get '#{folder_name}/tenant_partitions/:id', to: '#{folder_name}/tenant_partitions#show'"
|
|
64
65
|
end
|
|
65
66
|
|
|
66
67
|
# Muestra instrucciones post-generación en la consola.
|
|
@@ -2,7 +2,7 @@ module <%= @module_name %>
|
|
|
2
2
|
class TenantPartitionsController < ApplicationController
|
|
3
3
|
skip_before_action :verify_authenticity_token, raise: false
|
|
4
4
|
|
|
5
|
-
# POST
|
|
5
|
+
# POST /system/tenant_partitions
|
|
6
6
|
def create
|
|
7
7
|
partition_id = params.require(:id)
|
|
8
8
|
|
|
@@ -12,14 +12,25 @@ module <%= @module_name %>
|
|
|
12
12
|
TenantPartition.create!(partition_id)
|
|
13
13
|
render json: { message: "Tenant provisioned successfully" }, status: :created
|
|
14
14
|
end
|
|
15
|
+
rescue => e
|
|
16
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
# DELETE
|
|
19
|
+
# DELETE /system/tenant_partitions/:id
|
|
18
20
|
def destroy
|
|
19
21
|
partition_id = params.require(:id)
|
|
20
22
|
|
|
21
23
|
TenantPartition.destroy!(partition_id)
|
|
22
24
|
head :no_content
|
|
23
25
|
end
|
|
26
|
+
|
|
27
|
+
# GET /system/tenant_partitions/:id
|
|
28
|
+
def show
|
|
29
|
+
if TenantPartition.exists?(params[:id])
|
|
30
|
+
render json: { active: true, id: params[:id] }, status: :ok
|
|
31
|
+
else
|
|
32
|
+
render json: { active: false }, status: :not_found
|
|
33
|
+
end
|
|
34
|
+
end
|
|
24
35
|
end
|
|
25
36
|
end
|
|
@@ -2,15 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module TenantPartition
|
|
4
4
|
module Concerns
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Funcionalidad para mover registros desde la tabla DEFAULT hacia su partición correspondiente.
|
|
6
|
+
# Utilizado principalmente en tareas de mantenimiento y recuperación de datos.
|
|
7
7
|
module DataMover
|
|
8
8
|
extend ActiveSupport::Concern
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
# Se define como constante para evitar la ofensa Metrics/MethodLength de RuboCop
|
|
12
|
-
# y mejorar la performance evitando la re-asignación de memoria en cada llamada.
|
|
13
|
-
# @api private
|
|
10
|
+
# SQL optimizado para mover datos en masa (CTE + DELETE/INSERT).
|
|
14
11
|
MOVE_SQL = <<~SQL.squish.freeze
|
|
15
12
|
WITH moved_rows AS (
|
|
16
13
|
DELETE FROM %<default>s
|
|
@@ -24,14 +21,12 @@ module TenantPartition
|
|
|
24
21
|
SQL
|
|
25
22
|
private_constant :MOVE_SQL
|
|
26
23
|
|
|
27
|
-
# Mueve registros desde la tabla
|
|
28
|
-
#
|
|
24
|
+
# Mueve registros pertenecientes a este tenant desde la tabla Default a la partición.
|
|
25
|
+
# Se ejecuta en lotes para no bloquear la base de datos.
|
|
29
26
|
#
|
|
30
|
-
# @param batch_size [Integer]
|
|
31
|
-
# @return [Integer]
|
|
27
|
+
# @param batch_size [Integer] Tamaño del lote (default: 5000).
|
|
28
|
+
# @return [Integer] Cantidad total de registros movidos.
|
|
32
29
|
def populate_from_default(batch_size: 5000)
|
|
33
|
-
return 0 unless persisted?
|
|
34
|
-
|
|
35
30
|
ActiveSupport::Notifications.instrument("populate.tenant_partition", instrumentation_payload) do |evt|
|
|
36
31
|
total = perform_batch_move(batch_size)
|
|
37
32
|
evt[:count] = total
|
|
@@ -41,19 +36,19 @@ module TenantPartition
|
|
|
41
36
|
|
|
42
37
|
private
|
|
43
38
|
|
|
44
|
-
# Construye el payload de datos para la instrumentación de ActiveSupport.
|
|
45
|
-
# @return [Hash] Datos del contexto de la migración.
|
|
46
39
|
def instrumentation_payload
|
|
47
40
|
{
|
|
48
|
-
partition_key: self.class.
|
|
41
|
+
partition_key: self.class.partition_key_column,
|
|
49
42
|
value: partition_id,
|
|
50
|
-
parent_table: self.class.
|
|
43
|
+
parent_table: self.class.table_name
|
|
51
44
|
}
|
|
52
45
|
end
|
|
53
46
|
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
# Obtiene el valor del ID de partición de la instancia actual.
|
|
48
|
+
def partition_id
|
|
49
|
+
public_send(self.class.partition_key_column)
|
|
50
|
+
end
|
|
51
|
+
|
|
57
52
|
def perform_batch_move(batch_size)
|
|
58
53
|
total_moved = 0
|
|
59
54
|
loop do
|
|
@@ -64,26 +59,18 @@ module TenantPartition
|
|
|
64
59
|
total_moved
|
|
65
60
|
end
|
|
66
61
|
|
|
67
|
-
# Ejecuta una transacción atómica para mover un solo lote de registros.
|
|
68
|
-
# @param batch_size [Integer] Tamaño del lote.
|
|
69
|
-
# @return [Integer] Cantidad de filas afectadas (cmd_tuples).
|
|
70
62
|
def move_single_batch(batch_size)
|
|
71
63
|
self.class.connection.transaction do
|
|
72
|
-
# Kernel#format es más rápido y seguro que la interpolación directa para templates
|
|
73
64
|
sql = format(MOVE_SQL, move_query_params(batch_size))
|
|
74
65
|
self.class.connection.execute(sql).cmd_tuples
|
|
75
66
|
end
|
|
76
67
|
end
|
|
77
68
|
|
|
78
|
-
# Prepara los parámetros para inyectar en la plantilla SQL.
|
|
79
|
-
# Se extrajo a un método separado para reducir la longitud de `move_single_batch`.
|
|
80
|
-
# @param batch_size [Integer] Tamaño del lote.
|
|
81
|
-
# @return [Hash] Parámetros formateados para Kernel#format.
|
|
82
69
|
def move_query_params(batch_size)
|
|
83
70
|
{
|
|
84
|
-
default: self.class.
|
|
85
|
-
parent: self.class.
|
|
86
|
-
key: self.class.
|
|
71
|
+
default: self.class.default_partition_table_name,
|
|
72
|
+
parent: self.class.table_name,
|
|
73
|
+
key: self.class.partition_key_column,
|
|
87
74
|
val: partition_id,
|
|
88
75
|
batch_size: batch_size
|
|
89
76
|
}
|
|
@@ -1,63 +1,138 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "data_mover"
|
|
4
|
+
|
|
3
5
|
module TenantPartition
|
|
4
6
|
module Concerns
|
|
5
|
-
# Concern para
|
|
6
|
-
#
|
|
7
|
+
# Concern principal para dotar a un modelo ActiveRecord de capacidades de particionamiento.
|
|
8
|
+
#
|
|
9
|
+
# Al incluir este concern en ApplicationRecord, los modelos obtienen acceso a la macro
|
|
10
|
+
# {.partition_table}, la cual activa la lógica de partición, configura la clave primaria
|
|
11
|
+
# compuesta y añade métodos de gestión de tablas (DDL).
|
|
7
12
|
module Partitioned
|
|
8
13
|
extend ActiveSupport::Concern
|
|
9
14
|
|
|
10
|
-
included do
|
|
11
|
-
TenantPartition::Concerns::Partitioned.configure_model(self)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
15
|
class_methods do
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
# Macro para activar el particionamiento en el modelo actual.
|
|
17
|
+
#
|
|
18
|
+
# @example Activar partición por ISP
|
|
19
|
+
# class Conversation < ApplicationRecord
|
|
20
|
+
# partition_table key: :isp_id
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @param key [Symbol, nil] La columna clave de partición. Si es nil, usa la global.
|
|
24
|
+
# @return [void]
|
|
25
|
+
def partition_table(key: nil)
|
|
26
|
+
resolved_key = key || TenantPartition.configuration.partition_key
|
|
27
|
+
|
|
28
|
+
# Guardamos la key en una variable de instancia de clase para acceso rápido
|
|
29
|
+
@partition_key_column = resolved_key
|
|
30
|
+
|
|
31
|
+
# Registrar este modelo en el sistema
|
|
32
|
+
TenantPartition.register_model(self)
|
|
33
|
+
|
|
34
|
+
# Configurar Primary Key Compuesta (Soporte Rails 7.1+)
|
|
35
|
+
self.primary_key = [:id, resolved_key]
|
|
36
|
+
|
|
37
|
+
# Inyectar Scopes Automáticos
|
|
38
|
+
scope :for_partition, ->(val) { where(resolved_key => val) }
|
|
39
|
+
|
|
40
|
+
# Inyectar lógica de infraestructura y movimiento de datos
|
|
41
|
+
extend ManagementMethods
|
|
42
|
+
include TenantPartition::Concerns::DataMover
|
|
19
43
|
end
|
|
20
44
|
|
|
21
|
-
#
|
|
22
|
-
# @
|
|
23
|
-
def
|
|
24
|
-
|
|
45
|
+
# Devuelve el nombre de la columna usada para particionar este modelo.
|
|
46
|
+
# @return [Symbol]
|
|
47
|
+
def partition_key_column
|
|
48
|
+
@partition_key_column
|
|
25
49
|
end
|
|
26
50
|
end
|
|
27
51
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
# Métodos de gestión de infraestructura (DDL) inyectados como métodos de clase.
|
|
53
|
+
module ManagementMethods
|
|
54
|
+
# Crea físicamente la partición en la base de datos para un valor dado.
|
|
55
|
+
#
|
|
56
|
+
# @param value [String, Integer] El valor del tenant (ej: ID del ISP).
|
|
57
|
+
# @return [void]
|
|
58
|
+
# @raise [ActiveRecord::StatementInvalid] Si falla la ejecución SQL.
|
|
59
|
+
def create_partition(value)
|
|
60
|
+
payload = create_partition_payload(value)
|
|
33
61
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
ActiveSupport::Notifications.instrument("create.tenant_partition", payload) do
|
|
63
|
+
execute_create_partition_sql(value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
37
66
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
# Elimina (DROP) la partición asociada al valor dado.
|
|
68
|
+
# Realiza un DETACH primero para seguridad y luego DROP.
|
|
69
|
+
#
|
|
70
|
+
# @param value [String, Integer] El valor del tenant.
|
|
71
|
+
# @return [void]
|
|
72
|
+
def drop_partition(value)
|
|
73
|
+
partition_name = partition_table_name(value)
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
return infra_class.partition_key if infra_class.respond_to?(:partition_key)
|
|
75
|
+
return unless partition_table_exists?(value)
|
|
48
76
|
|
|
49
|
-
|
|
50
|
-
|
|
77
|
+
connection.transaction do
|
|
78
|
+
connection.execute("ALTER TABLE #{table_name} DETACH PARTITION #{partition_name};")
|
|
79
|
+
connection.execute("DROP TABLE IF EXISTS #{partition_name};")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
51
82
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
# Genera el nombre de la tabla física para una partición específica.
|
|
84
|
+
#
|
|
85
|
+
# @param value [Object] El valor del tenant.
|
|
86
|
+
# @return [String] Nombre de la tabla (ej: 'conversations_isp_1').
|
|
87
|
+
def partition_table_name(value)
|
|
88
|
+
sanitized_value = value.to_s.gsub("-", "_")
|
|
89
|
+
suffix = partition_key_column.to_s.gsub("_id", "")
|
|
58
90
|
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
# Formato: nombre_tabla_sufijo_valor
|
|
92
|
+
"#{table_name}_#{suffix}_#{sanitized_value}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Verifica si la tabla de la partición existe en el catálogo de PostgreSQL.
|
|
96
|
+
#
|
|
97
|
+
# @param value [Object] El valor del tenant.
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def partition_table_exists?(value)
|
|
100
|
+
child_table = partition_table_name(value)
|
|
101
|
+
|
|
102
|
+
sql = <<~SQL.squish
|
|
103
|
+
SELECT 1 FROM pg_class c
|
|
104
|
+
JOIN pg_inherits i ON c.oid = i.inhrelid
|
|
105
|
+
JOIN pg_class p ON i.inhparent = p.oid
|
|
106
|
+
WHERE p.relname = '#{table_name}' AND c.relname = '#{child_table}';
|
|
107
|
+
SQL
|
|
108
|
+
|
|
109
|
+
connection.execute(sql).any?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Nombre de la tabla DEFAULT (para valores que no caen en ninguna partición).
|
|
113
|
+
# @return [String]
|
|
114
|
+
def default_partition_table_name
|
|
115
|
+
"#{table_name}_default"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def create_partition_payload(value)
|
|
121
|
+
{
|
|
122
|
+
partition_key: partition_key_column,
|
|
123
|
+
value: value,
|
|
124
|
+
parent_table: table_name
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def execute_create_partition_sql(value)
|
|
129
|
+
table_name_for_partition = partition_table_name(value)
|
|
130
|
+
sql = <<~SQL.squish
|
|
131
|
+
CREATE TABLE IF NOT EXISTS #{table_name_for_partition}
|
|
132
|
+
PARTITION OF #{table_name} FOR VALUES IN ('#{value}');
|
|
133
|
+
SQL
|
|
134
|
+
connection.execute(sql)
|
|
135
|
+
end
|
|
61
136
|
end
|
|
62
137
|
end
|
|
63
138
|
end
|
|
@@ -1,79 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module TenantPartition
|
|
4
|
-
# Módulo
|
|
5
|
-
#
|
|
4
|
+
# Módulo de utilidades para el mantenimiento de la salud de las particiones.
|
|
5
|
+
# Permite auditar tablas DEFAULT en busca de registros huérfanos y moverlos.
|
|
6
6
|
module Maintenance
|
|
7
|
-
# Audita todas las tablas
|
|
8
|
-
# Un registro huérfano es aquel que cayó en la tabla default porque su partición no existía.
|
|
7
|
+
# Audita todas las tablas particionadas y cuenta registros en la tabla DEFAULT.
|
|
9
8
|
#
|
|
10
|
-
# @return [Hash{String => Integer}] Reporte con
|
|
9
|
+
# @return [Hash{String => Integer}] Reporte con el conteo de huérfanos por modelo.
|
|
11
10
|
def audit
|
|
12
|
-
ensure_models_loaded!
|
|
13
|
-
log_info "AUDIT", "Iniciando auditoría de tablas DEFAULT..."
|
|
11
|
+
TenantPartition.send(:ensure_models_loaded!)
|
|
12
|
+
TenantPartition.log_info "AUDIT", "Iniciando auditoría de tablas DEFAULT..."
|
|
14
13
|
|
|
15
|
-
partitionable_models.each_with_object({}) do |model, report|
|
|
14
|
+
TenantPartition.partitionable_models.each_with_object({}) do |model, report|
|
|
16
15
|
count = count_default_rows(model)
|
|
17
16
|
report[model.name] = count
|
|
18
17
|
log_audit_result(model, count)
|
|
19
18
|
end
|
|
20
19
|
end
|
|
21
20
|
|
|
22
|
-
# Ejecuta el proceso de limpieza
|
|
23
|
-
#
|
|
21
|
+
# Ejecuta el proceso de limpieza: busca huérfanos y los mueve a su partición correspondiente.
|
|
22
|
+
# Si la partición no existe, lanza un error en el log.
|
|
24
23
|
#
|
|
25
24
|
# @return [void]
|
|
26
25
|
def cleanup!
|
|
27
|
-
|
|
28
|
-
key = configuration.partition_key
|
|
29
|
-
log_info "CLEANUP", "Iniciando proceso de limpieza global..."
|
|
26
|
+
TenantPartition.log_info "CLEANUP", "Iniciando proceso de limpieza global..."
|
|
30
27
|
|
|
31
|
-
partitionable_models.each do |model|
|
|
32
|
-
|
|
28
|
+
TenantPartition.partitionable_models.each do |model|
|
|
29
|
+
# Usamos la key específica del modelo por si fue configurada localmente
|
|
30
|
+
model_key = model.partition_key_column
|
|
31
|
+
process_cleanup_for_model(model, model_key)
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
private
|
|
37
36
|
|
|
38
|
-
# Registra el resultado de una auditoría en el log.
|
|
39
37
|
def log_audit_result(model, count)
|
|
40
38
|
if count.positive?
|
|
41
|
-
log_warn "ALERTA", "#{model.name}: #{count} registros huérfanos encontrados."
|
|
39
|
+
TenantPartition.log_warn "ALERTA", "#{model.name}: #{count} registros huérfanos encontrados."
|
|
42
40
|
else
|
|
43
|
-
log_info "OK", "#{model.name}: Tabla default limpia."
|
|
41
|
+
TenantPartition.log_info "OK", "#{model.name}: Tabla default limpia."
|
|
44
42
|
end
|
|
45
43
|
end
|
|
46
44
|
|
|
47
|
-
# Orquesta la limpieza para un modelo específico.
|
|
48
45
|
def process_cleanup_for_model(model, key)
|
|
49
46
|
orphan_ids = fetch_orphan_ids(model, key)
|
|
50
|
-
return log_info("OK", "#{model.name}: Sin datos huérfanos.") if orphan_ids.empty?
|
|
47
|
+
return TenantPartition.log_info("OK", "#{model.name}: Sin datos huérfanos.") if orphan_ids.empty?
|
|
51
48
|
|
|
52
|
-
log_warn "FIX", "#{model.name}: Procesando #{orphan_ids.count} tenants con datos huérfanos."
|
|
49
|
+
TenantPartition.log_warn "FIX", "#{model.name}: Procesando #{orphan_ids.count} tenants con datos huérfanos."
|
|
53
50
|
orphan_ids.each { |id| move_orphans(model, id) }
|
|
54
51
|
end
|
|
55
52
|
|
|
56
|
-
# Mueve los huérfanos de un tenant específico.
|
|
57
53
|
def move_orphans(model, id)
|
|
58
|
-
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
# Verificamos si la tabla destino existe antes de intentar mover
|
|
55
|
+
if model.partition_table_exists?(id)
|
|
56
|
+
# Instanciamos el modelo solo para usar el DataMover
|
|
57
|
+
instance = model.new(model.partition_key_column => id)
|
|
58
|
+
|
|
59
|
+
moved = instance.populate_from_default
|
|
60
|
+
TenantPartition.log_info "MOVE", " -> ID #{id}: #{moved} registros recuperados."
|
|
62
61
|
else
|
|
63
|
-
log_error "ERROR", " -> ID #{id}: La partición física no existe. Cree el tenant primero."
|
|
62
|
+
TenantPartition.log_error "ERROR", " -> ID #{id}: La partición física no existe. Cree el tenant primero."
|
|
64
63
|
end
|
|
65
64
|
end
|
|
66
65
|
|
|
67
|
-
# Cuenta las filas en la tabla default de un modelo.
|
|
68
66
|
def count_default_rows(model)
|
|
69
|
-
model.connection.select_value("SELECT count(*) FROM #{model.
|
|
67
|
+
model.connection.select_value("SELECT count(*) FROM #{model.default_partition_table_name}").to_i
|
|
70
68
|
rescue ActiveRecord::StatementInvalid
|
|
71
69
|
0
|
|
72
70
|
end
|
|
73
71
|
|
|
74
|
-
# Obtiene los IDs únicos de los registros atrapados en la tabla default.
|
|
75
72
|
def fetch_orphan_ids(model, key)
|
|
76
|
-
sql = "SELECT DISTINCT #{key} FROM #{model.
|
|
73
|
+
sql = "SELECT DISTINCT #{key} FROM #{model.default_partition_table_name} WHERE #{key} IS NOT NULL"
|
|
77
74
|
model.connection.execute(sql).map { |r| r[key.to_s] }
|
|
78
75
|
rescue ActiveRecord::StatementInvalid
|
|
79
76
|
[]
|
data/lib/tenant_partition.rb
CHANGED
|
@@ -7,7 +7,6 @@ require "active_support/all"
|
|
|
7
7
|
require_relative "tenant_partition/version"
|
|
8
8
|
require_relative "tenant_partition/configuration"
|
|
9
9
|
require_relative "tenant_partition/safety_guard"
|
|
10
|
-
require_relative "tenant_partition/base"
|
|
11
10
|
require_relative "tenant_partition/maintenance"
|
|
12
11
|
require_relative "tenant_partition/concerns/partitioned"
|
|
13
12
|
require_relative "tenant_partition/concerns/controller"
|
|
@@ -15,29 +14,62 @@ require_relative "tenant_partition/concerns/controller"
|
|
|
15
14
|
require_relative "tenant_partition/railtie" if defined?(Rails)
|
|
16
15
|
|
|
17
16
|
# Fachada principal para la gestión y orquestación de particionamiento en PostgreSQL.
|
|
18
|
-
#
|
|
17
|
+
# Permite configurar la gema y ejecutar comandos globales de creación/eliminación de tenants.
|
|
19
18
|
module TenantPartition
|
|
20
19
|
class Error < StandardError; end
|
|
21
20
|
|
|
22
|
-
# Incorpora las funcionalidades de Auditoría y Limpieza.
|
|
23
21
|
extend Maintenance
|
|
24
22
|
|
|
25
23
|
class << self
|
|
26
|
-
# @return [TenantPartition::Configuration]
|
|
24
|
+
# @return [TenantPartition::Configuration] la configuración actual.
|
|
27
25
|
attr_accessor :configuration
|
|
28
26
|
|
|
29
|
-
#
|
|
30
|
-
# @
|
|
27
|
+
# Array para almacenar los modelos que han activado el particionamiento.
|
|
28
|
+
# @return [Array<Class>] Lista de clases ActiveRecord registradas.
|
|
29
|
+
def registered_models
|
|
30
|
+
@registered_models ||= []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Registra un modelo como particionado.
|
|
34
|
+
# Método llamado automáticamente por el concern {TenantPartition::Concerns::Partitioned}.
|
|
35
|
+
#
|
|
36
|
+
# @api private
|
|
37
|
+
# @param model [Class] La clase del modelo a registrar.
|
|
38
|
+
# @return [Array<Class>] La lista actualizada de modelos.
|
|
39
|
+
def register_model(model)
|
|
40
|
+
registered_models << model
|
|
41
|
+
registered_models.uniq!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Devuelve la lista de modelos particionados activos en la aplicación.
|
|
45
|
+
# @return [Array<Class>] Lista de modelos.
|
|
46
|
+
def partitionable_models
|
|
47
|
+
registered_models
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Bloque de configuración global para la gema.
|
|
51
|
+
#
|
|
52
|
+
# @example Configurar el ISP ID como clave
|
|
53
|
+
# TenantPartition.configure do |config|
|
|
54
|
+
# config.partition_key = :isp_id
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# @yield [configuration] Objeto de configuración.
|
|
58
|
+
# @return [void]
|
|
31
59
|
def configure
|
|
32
60
|
self.configuration ||= Configuration.new
|
|
33
61
|
yield(configuration)
|
|
34
62
|
SafetyGuard.validate!
|
|
35
63
|
end
|
|
36
64
|
|
|
37
|
-
# Crea
|
|
38
|
-
#
|
|
65
|
+
# Crea la infraestructura de particiones (tablas) para un tenant específico.
|
|
66
|
+
# Itera sobre todos los modelos registrados y crea su tabla particionada correspondiente.
|
|
67
|
+
#
|
|
68
|
+
# @param partition_id [Integer, String] El identificador del tenant (ej. ISP ID).
|
|
69
|
+
# @return [void]
|
|
39
70
|
def create!(partition_id)
|
|
40
71
|
ensure_models_loaded!
|
|
72
|
+
|
|
41
73
|
log_info "CREATE", "Iniciando aprovisionamiento para ID: #{partition_id}"
|
|
42
74
|
|
|
43
75
|
partitionable_models.each do |model|
|
|
@@ -45,10 +77,14 @@ module TenantPartition
|
|
|
45
77
|
end
|
|
46
78
|
end
|
|
47
79
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
80
|
+
# Destruye la infraestructura de particiones para un tenant específico.
|
|
81
|
+
# ¡CUIDADO! Esto elimina físicamente las tablas y sus datos.
|
|
82
|
+
#
|
|
83
|
+
# @param partition_id [Integer, String] El identificador del tenant.
|
|
84
|
+
# @return [void]
|
|
50
85
|
def destroy!(partition_id)
|
|
51
86
|
ensure_models_loaded!
|
|
87
|
+
|
|
52
88
|
log_info "DESTROY", "Eliminando infraestructura para ID: #{partition_id}"
|
|
53
89
|
|
|
54
90
|
partitionable_models.each do |model|
|
|
@@ -57,57 +93,44 @@ module TenantPartition
|
|
|
57
93
|
end
|
|
58
94
|
|
|
59
95
|
# Verifica si existe infraestructura creada para un tenant.
|
|
60
|
-
#
|
|
61
|
-
# @
|
|
96
|
+
#
|
|
97
|
+
# @param partition_id [Integer, String] El identificador del tenant.
|
|
98
|
+
# @return [Boolean] true si al menos un modelo tiene la tabla creada.
|
|
62
99
|
def exists?(partition_id)
|
|
63
|
-
|
|
64
|
-
partitionable_models.any? { |model| model.exists?(partition_id) }
|
|
100
|
+
partitionable_models.any? { |model| model.partition_table_exists?(partition_id) }
|
|
65
101
|
end
|
|
66
102
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
# Fuerza la carga de los modelos de partición en entornos con Lazy Loading (Dev).
|
|
70
|
-
def ensure_models_loaded!
|
|
71
|
-
return unless defined?(Rails)
|
|
72
|
-
|
|
73
|
-
partition_dir = Rails.root.join("app/models/partition")
|
|
74
|
-
return unless Dir.exist?(partition_dir)
|
|
75
|
-
|
|
76
|
-
Dir[partition_dir.join("**/*.rb")].each { |file| require_dependency file }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Retorna todas las subclases de TenantPartition::Base cargadas en memoria.
|
|
80
|
-
# @return [Array<Class>] Lista de clases de modelos.
|
|
81
|
-
def partitionable_models
|
|
82
|
-
ObjectSpace.each_object(Class).select { |klass| klass < TenantPartition::Base }
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# --- Logging ---
|
|
86
|
-
|
|
103
|
+
# @api private
|
|
87
104
|
def log_info(tag, msg) = logger&.info(format_log(tag, msg))
|
|
105
|
+
# @api private
|
|
88
106
|
def log_warn(tag, msg) = logger&.warn(format_log(tag, msg))
|
|
107
|
+
# @api private
|
|
89
108
|
def log_error(tag, msg) = logger&.error(format_log(tag, msg))
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
90
112
|
def format_log(tag, msg) = "[TenantPartition] [#{tag}] #{msg}"
|
|
91
113
|
def logger = (defined?(Rails) ? Rails.logger : Logger.new($stdout))
|
|
92
114
|
|
|
93
|
-
|
|
115
|
+
def ensure_models_loaded!
|
|
116
|
+
return unless defined?(Rails) && !Rails.configuration.eager_load
|
|
117
|
+
|
|
118
|
+
Rails.application.eager_load!
|
|
119
|
+
end
|
|
94
120
|
|
|
95
121
|
def process_creation(model, partition_id)
|
|
96
|
-
if model.
|
|
122
|
+
if model.partition_table_exists?(partition_id)
|
|
97
123
|
log_info "SKIP", "#{model.name}: La partición ya existe."
|
|
98
124
|
else
|
|
99
|
-
model.
|
|
125
|
+
model.create_partition(partition_id)
|
|
100
126
|
log_info "OK", "#{model.name}: Partición creada exitosamente."
|
|
101
127
|
end
|
|
102
128
|
end
|
|
103
129
|
|
|
104
130
|
def process_destruction(model, partition_id)
|
|
105
|
-
if model.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
else
|
|
109
|
-
log_error "FAIL", "#{model.name}: No se pudo eliminar la partición."
|
|
110
|
-
end
|
|
131
|
+
if model.partition_table_exists?(partition_id)
|
|
132
|
+
model.drop_partition(partition_id)
|
|
133
|
+
log_info "DROP", "#{model.name}: Partición eliminada."
|
|
111
134
|
else
|
|
112
135
|
log_info "SKIP", "#{model.name}: No existe partición para borrar."
|
|
113
136
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tenant_partition
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gabriel
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activemodel
|
|
@@ -17,9 +17,6 @@ dependencies:
|
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
19
|
version: '7.1'
|
|
20
|
-
- - "<"
|
|
21
|
-
- !ruby/object:Gem::Version
|
|
22
|
-
version: '9.0'
|
|
23
20
|
type: :runtime
|
|
24
21
|
prerelease: false
|
|
25
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -27,9 +24,6 @@ dependencies:
|
|
|
27
24
|
- - ">="
|
|
28
25
|
- !ruby/object:Gem::Version
|
|
29
26
|
version: '7.1'
|
|
30
|
-
- - "<"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '9.0'
|
|
33
27
|
- !ruby/object:Gem::Dependency
|
|
34
28
|
name: activerecord
|
|
35
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -37,9 +31,6 @@ dependencies:
|
|
|
37
31
|
- - ">="
|
|
38
32
|
- !ruby/object:Gem::Version
|
|
39
33
|
version: '7.1'
|
|
40
|
-
- - "<"
|
|
41
|
-
- !ruby/object:Gem::Version
|
|
42
|
-
version: '9.0'
|
|
43
34
|
type: :runtime
|
|
44
35
|
prerelease: false
|
|
45
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -47,9 +38,6 @@ dependencies:
|
|
|
47
38
|
- - ">="
|
|
48
39
|
- !ruby/object:Gem::Version
|
|
49
40
|
version: '7.1'
|
|
50
|
-
- - "<"
|
|
51
|
-
- !ruby/object:Gem::Version
|
|
52
|
-
version: '9.0'
|
|
53
41
|
description: Framework de infraestructura para Rails 7.1+ que automatiza el particionamiento
|
|
54
42
|
nativo (List Partitioning). Incluye soporte para Composite Primary Keys, orquestación
|
|
55
43
|
de tenants y migraciones zero-downtime.
|
|
@@ -67,7 +55,6 @@ files:
|
|
|
67
55
|
- lib/generators/tenant_partition/templates/README
|
|
68
56
|
- lib/generators/tenant_partition/templates/tenant_partitions_controller.rb.erb
|
|
69
57
|
- lib/tenant_partition.rb
|
|
70
|
-
- lib/tenant_partition/base.rb
|
|
71
58
|
- lib/tenant_partition/concerns/controller.rb
|
|
72
59
|
- lib/tenant_partition/concerns/data_mover.rb
|
|
73
60
|
- lib/tenant_partition/concerns/partitioned.rb
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "concerns/data_mover"
|
|
4
|
-
|
|
5
|
-
module TenantPartition
|
|
6
|
-
# Clase base abstracta para definir modelos de infraestructura de particionamiento.
|
|
7
|
-
# Hereda de esta clase para habilitar operaciones DDL (Create/Drop) sobre tus tablas particionadas.
|
|
8
|
-
#
|
|
9
|
-
# @abstract
|
|
10
|
-
class Base
|
|
11
|
-
include ActiveModel::Model
|
|
12
|
-
include ActiveModel::Attributes
|
|
13
|
-
include TenantPartition::Concerns::DataMover
|
|
14
|
-
|
|
15
|
-
# Hook de herencia para definir automáticamente el atributo de partición en las subclases.
|
|
16
|
-
# @param subclass [Class] La clase que hereda.
|
|
17
|
-
def self.inherited(subclass)
|
|
18
|
-
super
|
|
19
|
-
key = TenantPartition.configuration&.partition_key
|
|
20
|
-
subclass.attribute key if key
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
class << self
|
|
24
|
-
attr_writer :parent_table, :prefix, :default_table
|
|
25
|
-
|
|
26
|
-
# @return [String] Nombre de la tabla padre (ej: 'conversations').
|
|
27
|
-
def parent_table
|
|
28
|
-
@parent_table ||= name.demodulize.underscore.pluralize
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# @return [Symbol] Clave de partición configurada (ej: :isp_id).
|
|
32
|
-
# @raise [TenantPartition::Error] Si no hay configuración global ni local.
|
|
33
|
-
def partition_key
|
|
34
|
-
@partition_key ||= TenantPartition.configuration&.partition_key ||
|
|
35
|
-
raise(TenantPartition::Error, "Clave de partición no configurada.")
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Define manualmente la clave de partición para esta clase, sobrescribiendo la global.
|
|
39
|
-
# @param value [Symbol] Nombre de la columna.
|
|
40
|
-
def partition_key=(value)
|
|
41
|
-
@partition_key = value
|
|
42
|
-
attribute value
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# @return [String] Prefijo para las tablas particionadas (ej: 'conversations_isp').
|
|
46
|
-
def prefix
|
|
47
|
-
@prefix ||= "#{parent_table}_#{partition_key.to_s.gsub("_id", "")}"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# @return [String] Nombre de la tabla DEFAULT.
|
|
51
|
-
def default_table
|
|
52
|
-
@default_table ||= "#{parent_table}_default"
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# @return [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter] Conexión activa a la DB.
|
|
56
|
-
def connection
|
|
57
|
-
ActiveRecord::Base.connection
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Crea una nueva partición física en la base de datos.
|
|
61
|
-
# @param value [String, Integer] El valor discriminador del tenant (ej: UUID).
|
|
62
|
-
# @return [TenantPartition::Base] Una instancia representando la nueva partición.
|
|
63
|
-
def create(value)
|
|
64
|
-
payload = { partition_key: partition_key, value: value, table: parent_table }
|
|
65
|
-
|
|
66
|
-
ActiveSupport::Notifications.instrument("create.tenant_partition", payload) do
|
|
67
|
-
name = partition_name(value)
|
|
68
|
-
sql = "CREATE TABLE IF NOT EXISTS #{name} PARTITION OF #{parent_table} FOR VALUES IN ('#{value}');"
|
|
69
|
-
connection.execute(sql)
|
|
70
|
-
new(partition_key => value)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Genera el nombre físico de la tabla particionada, sanitizando el valor.
|
|
75
|
-
# @param value [Object] Valor del tenant.
|
|
76
|
-
# @return [String] Nombre de la tabla (ej: 'conversations_isp_123').
|
|
77
|
-
def partition_name(value)
|
|
78
|
-
sanitized = value.to_s.gsub("-", "_")
|
|
79
|
-
"#{prefix}_#{sanitized}"
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Verifica si la tabla particionada existe físicamente en el catálogo de PostgreSQL.
|
|
83
|
-
# @param value [Object] Valor del tenant.
|
|
84
|
-
# @return [Boolean] true si la tabla existe.
|
|
85
|
-
def exists?(value)
|
|
86
|
-
name = partition_name(value)
|
|
87
|
-
sql = <<~SQL.squish
|
|
88
|
-
SELECT 1 FROM pg_class c
|
|
89
|
-
JOIN pg_inherits i ON c.oid = i.inhrelid
|
|
90
|
-
JOIN pg_class p ON i.inhparent = p.oid
|
|
91
|
-
WHERE p.relname = '#{parent_table}' AND c.relname = '#{name}';
|
|
92
|
-
SQL
|
|
93
|
-
connection.execute(sql).any?
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Busca una partición existente.
|
|
97
|
-
# @param value [Object] Valor del tenant.
|
|
98
|
-
# @return [TenantPartition::Base, nil] Instancia si existe, nil si no.
|
|
99
|
-
def find(value)
|
|
100
|
-
new(partition_key => value) if exists?(value)
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# --- Métodos de Instancia ---
|
|
105
|
-
|
|
106
|
-
# @return [Object] Valor del ID de partición de esta instancia.
|
|
107
|
-
def partition_id
|
|
108
|
-
public_send(self.class.partition_key)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# @return [String] Nombre de la tabla física correspondiente a esta instancia.
|
|
112
|
-
def partition_table_name
|
|
113
|
-
self.class.partition_name(partition_id)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# @return [Boolean] Si la partición está persistida en base de datos.
|
|
117
|
-
def persisted?
|
|
118
|
-
self.class.exists?(partition_id)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Elimina la partición física de la base de datos (DETACH + DROP).
|
|
122
|
-
# @return [Boolean] true si la eliminación fue exitosa.
|
|
123
|
-
def destroy
|
|
124
|
-
return false unless persisted?
|
|
125
|
-
|
|
126
|
-
p_name = partition_table_name
|
|
127
|
-
p_table = self.class.parent_table
|
|
128
|
-
|
|
129
|
-
self.class.connection.transaction do
|
|
130
|
-
self.class.connection.execute("ALTER TABLE #{p_table} DETACH PARTITION #{p_name};")
|
|
131
|
-
self.class.connection.execute("DROP TABLE IF EXISTS #{p_name};")
|
|
132
|
-
end
|
|
133
|
-
true
|
|
134
|
-
rescue ActiveRecord::StatementInvalid
|
|
135
|
-
false
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
end
|