tenant_partition 0.1.1 → 0.1.3
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 +10 -0
- data/README.md +13 -1
- data/lib/generators/tenant_partition/api_controller_generator.rb +78 -0
- data/lib/generators/tenant_partition/templates/README +13 -0
- data/lib/generators/tenant_partition/templates/tenant_partitions_controller.rb.erb +25 -0
- data/lib/tenant_partition/base.rb +38 -67
- data/lib/tenant_partition/concerns/controller.rb +17 -41
- data/lib/tenant_partition/concerns/data_mover.rb +93 -0
- data/lib/tenant_partition/concerns/partitioned.rb +15 -69
- data/lib/tenant_partition/configuration.rb +13 -5
- data/lib/tenant_partition/maintenance.rb +82 -0
- data/lib/tenant_partition/safety_guard.rb +29 -30
- data/lib/tenant_partition/schema/statements.rb +40 -40
- data/lib/tenant_partition/version.rb +1 -1
- data/lib/tenant_partition.rb +50 -148
- metadata +21 -4
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Namespace principal de la gema.
|
|
3
4
|
module TenantPartition
|
|
5
|
+
# Clase contenedora de la configuración global de la gema.
|
|
4
6
|
class Configuration
|
|
5
|
-
# @return [Symbol, nil] Columna de base de datos (ej: :isp_id)
|
|
7
|
+
# @return [Symbol, nil] Columna de base de datos usada para particionar (ej: :isp_id).
|
|
6
8
|
attr_accessor :partition_key
|
|
7
9
|
|
|
8
|
-
# @return [String, nil] Nombre del Header HTTP
|
|
10
|
+
# @return [String, nil] Nombre del Header HTTP para identificación (ej: 'X-Tenant-ID').
|
|
9
11
|
attr_accessor :header_name
|
|
10
12
|
|
|
11
13
|
def initialize
|
|
@@ -13,21 +15,27 @@ module TenantPartition
|
|
|
13
15
|
@header_name = nil
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
# Valida si la configuración mínima requerida está presente.
|
|
19
|
+
# @return [Boolean] true si es válida.
|
|
16
20
|
def valid?
|
|
17
21
|
!partition_key.nil?
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
class << self
|
|
26
|
+
# @return [TenantPartition::Configuration] La instancia actual de configuración.
|
|
22
27
|
attr_accessor :configuration
|
|
23
28
|
|
|
29
|
+
# Punto de entrada para configurar la gema.
|
|
30
|
+
# @yieldparam [TenantPartition::Configuration] config
|
|
31
|
+
# @raise [TenantPartition::Error] Si la configuración resultante es inválida.
|
|
24
32
|
def configure
|
|
25
33
|
self.configuration ||= Configuration.new
|
|
26
34
|
yield(configuration)
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
return if configuration.valid?
|
|
37
|
+
|
|
38
|
+
raise TenantPartition::Error, "Debe configurar un 'partition_key' en el inicializador de TenantPartition."
|
|
31
39
|
end
|
|
32
40
|
end
|
|
33
41
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TenantPartition
|
|
4
|
+
# Módulo Mixin que agrega capacidades de mantenimiento (Auditoría y Limpieza)
|
|
5
|
+
# a la fachada principal.
|
|
6
|
+
module Maintenance
|
|
7
|
+
# Audita todas las tablas DEFAULT del sistema buscando registros huérfanos.
|
|
8
|
+
# Un registro huérfano es aquel que cayó en la tabla default porque su partición no existía.
|
|
9
|
+
#
|
|
10
|
+
# @return [Hash{String => Integer}] Reporte con nombre del modelo y cantidad de huérfanos.
|
|
11
|
+
def audit
|
|
12
|
+
ensure_models_loaded!
|
|
13
|
+
log_info "AUDIT", "Iniciando auditoría de tablas DEFAULT..."
|
|
14
|
+
|
|
15
|
+
partitionable_models.each_with_object({}) do |model, report|
|
|
16
|
+
count = count_default_rows(model)
|
|
17
|
+
report[model.name] = count
|
|
18
|
+
log_audit_result(model, count)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ejecuta el proceso de limpieza global.
|
|
23
|
+
# Identifica registros huérfanos y los mueve a sus particiones correspondientes.
|
|
24
|
+
#
|
|
25
|
+
# @return [void]
|
|
26
|
+
def cleanup!
|
|
27
|
+
ensure_models_loaded!
|
|
28
|
+
key = configuration.partition_key
|
|
29
|
+
log_info "CLEANUP", "Iniciando proceso de limpieza global..."
|
|
30
|
+
|
|
31
|
+
partitionable_models.each do |model|
|
|
32
|
+
process_cleanup_for_model(model, key)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Registra el resultado de una auditoría en el log.
|
|
39
|
+
def log_audit_result(model, count)
|
|
40
|
+
if count.positive?
|
|
41
|
+
log_warn "ALERTA", "#{model.name}: #{count} registros huérfanos encontrados."
|
|
42
|
+
else
|
|
43
|
+
log_info "OK", "#{model.name}: Tabla default limpia."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Orquesta la limpieza para un modelo específico.
|
|
48
|
+
def process_cleanup_for_model(model, key)
|
|
49
|
+
orphan_ids = fetch_orphan_ids(model, key)
|
|
50
|
+
return log_info("OK", "#{model.name}: Sin datos huérfanos.") if orphan_ids.empty?
|
|
51
|
+
|
|
52
|
+
log_warn "FIX", "#{model.name}: Procesando #{orphan_ids.count} tenants con datos huérfanos."
|
|
53
|
+
orphan_ids.each { |id| move_orphans(model, id) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Mueve los huérfanos de un tenant específico.
|
|
57
|
+
def move_orphans(model, id)
|
|
58
|
+
partition = model.find(id)
|
|
59
|
+
if partition
|
|
60
|
+
moved = partition.populate_from_default
|
|
61
|
+
log_info "MOVE", " -> ID #{id}: #{moved} registros recuperados."
|
|
62
|
+
else
|
|
63
|
+
log_error "ERROR", " -> ID #{id}: La partición física no existe. Cree el tenant primero."
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Cuenta las filas en la tabla default de un modelo.
|
|
68
|
+
def count_default_rows(model)
|
|
69
|
+
model.connection.select_value("SELECT count(*) FROM #{model.default_table}").to_i
|
|
70
|
+
rescue ActiveRecord::StatementInvalid
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Obtiene los IDs únicos de los registros atrapados en la tabla default.
|
|
75
|
+
def fetch_orphan_ids(model, key)
|
|
76
|
+
sql = "SELECT DISTINCT #{key} FROM #{model.default_table} WHERE #{key} IS NOT NULL"
|
|
77
|
+
model.connection.execute(sql).map { |r| r[key.to_s] }
|
|
78
|
+
rescue ActiveRecord::StatementInvalid
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -1,48 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module TenantPartition
|
|
4
|
-
# Clase encargada de validar
|
|
5
|
-
# para el funcionamiento de TenantPartition.
|
|
4
|
+
# Clase interna encargada de validar prerequisitos del entorno y base de datos.
|
|
6
5
|
class SafetyGuard
|
|
7
|
-
# Versión mínima de PostgreSQL
|
|
6
|
+
# Versión mínima de PostgreSQL requerida.
|
|
8
7
|
MIN_POSTGRES_VERSION = 13.0
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
class << self
|
|
10
|
+
# Ejecuta todas las validaciones de seguridad.
|
|
11
|
+
# @raise [TenantPartition::Error] Si alguna validación falla.
|
|
12
|
+
def validate!
|
|
13
|
+
check_configuration!
|
|
14
|
+
return unless defined?(Rails) && Rails.env.to_s != "test"
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
check_database_adapter!
|
|
17
|
+
check_postgres_version!
|
|
18
|
+
end
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def check_configuration!
|
|
23
|
+
return unless TenantPartition.configuration.partition_key.nil?
|
|
22
24
|
|
|
23
|
-
# Verifica que la clave de partición esté presente.
|
|
24
|
-
def self.check_configuration!
|
|
25
|
-
if TenantPartition.configuration.partition_key.nil?
|
|
26
25
|
raise TenantPartition::Error, "Falta configuración: 'partition_key' no ha sido definido."
|
|
27
26
|
end
|
|
28
|
-
end
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
raise TenantPartition::Error, "Adaptador incompatible: TenantPartition solo soporta
|
|
28
|
+
def check_database_adapter!
|
|
29
|
+
adapter = ActiveRecord::Base.connection_db_config.adapter
|
|
30
|
+
return if adapter == "postgresql"
|
|
31
|
+
|
|
32
|
+
raise TenantPartition::Error, "Adaptador incompatible: TenantPartition solo soporta " \
|
|
33
|
+
"PostgreSQL (usando: #{adapter})."
|
|
35
34
|
end
|
|
36
|
-
end
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
raise TenantPartition::Error, "Versión de PostgreSQL insuficiente: Se requiere
|
|
36
|
+
def check_postgres_version!
|
|
37
|
+
version = ActiveRecord::Base.connection.select_value("SHOW server_version").to_f
|
|
38
|
+
return if version >= MIN_POSTGRES_VERSION
|
|
39
|
+
|
|
40
|
+
raise TenantPartition::Error, "Versión de PostgreSQL insuficiente: Se requiere " \
|
|
41
|
+
"v#{MIN_POSTGRES_VERSION}+ (detectada: #{version})."
|
|
42
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
43
|
+
# Si no hay conexión aún, se posterga la validación hasta el primer uso real
|
|
43
44
|
end
|
|
44
|
-
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
45
|
-
# Si no hay conexión aún, se posterga la validación
|
|
46
45
|
end
|
|
47
46
|
end
|
|
48
47
|
end
|
|
@@ -2,65 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
module TenantPartition
|
|
4
4
|
module Schema
|
|
5
|
-
#
|
|
6
|
-
# enfocados en la automatización del particionamiento nativo de PostgreSQL.
|
|
5
|
+
# Módulo que extiende ActiveRecord::Migration con DSL para particionamiento.
|
|
7
6
|
module Statements
|
|
8
|
-
# Crea una tabla
|
|
9
|
-
#
|
|
7
|
+
# Crea una tabla particionada por lista y su tabla DEFAULT asociada.
|
|
8
|
+
# Configura automáticamente Primary Keys Compuestas para compatibilidad con Postgres.
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# @
|
|
15
|
-
# @param options [Hash] Opciones estándar + :partition_key opcional.
|
|
16
|
-
# @yield [t] Bloque para definir las columnas de la tabla.
|
|
10
|
+
# @param table_name [Symbol] Nombre de la tabla.
|
|
11
|
+
# @param options [Hash] Opciones de migración estándar.
|
|
12
|
+
# @option options [Symbol] :partition_key Clave opcional para sobreescribir la global.
|
|
13
|
+
# @yield [t] Bloque de definición de tabla (ActiveRecord::ConnectionAdapters::TableDefinition).
|
|
17
14
|
def create_partitioned_table(table_name, **options, &block)
|
|
18
|
-
# Prioridad: 1. Opción pasada al método, 2. Configuración global
|
|
19
15
|
key = options.delete(:partition_key) || TenantPartition.configuration&.partition_key
|
|
16
|
+
raise TenantPartition::Error, "Falta 'partition_key'." unless key
|
|
17
|
+
|
|
18
|
+
configure_pk_and_options(options, key)
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
create_table(table_name, **options) do |t|
|
|
21
|
+
setup_partitioning(t, key, block)
|
|
23
22
|
end
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
options[:id] = false
|
|
24
|
+
create_default_partition(table_name)
|
|
25
|
+
end
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
# Elimina una tabla particionada en cascada.
|
|
28
|
+
# @param table_name [Symbol] Nombre de la tabla.
|
|
29
|
+
def drop_partitioned_table(table_name)
|
|
30
|
+
drop_table(table_name, cascade: true)
|
|
31
|
+
end
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
options[:options] = "PARTITION BY LIST (#{key})"
|
|
33
|
+
private
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
# Configura las columnas y definiciones dentro del bloque create_table.
|
|
36
|
+
def setup_partitioning(table, key, block)
|
|
37
|
+
table.uuid :id, null: false, default: -> { "gen_random_uuid()" }
|
|
38
|
+
block.call(table)
|
|
39
|
+
ensure_partition_column(table, key)
|
|
40
|
+
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Prepara las opciones de PK compuesta y tipo de partición.
|
|
43
|
+
def configure_pk_and_options(options, key)
|
|
44
|
+
options[:id] = false
|
|
45
|
+
options[:primary_key] = [:id, key]
|
|
46
|
+
options[:options] = "PARTITION BY LIST (#{key})"
|
|
47
|
+
end
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
t.column key, :string, null: false
|
|
49
|
-
end
|
|
50
|
-
end
|
|
49
|
+
# Asegura que la columna de partición exista si el usuario olvidó definirla.
|
|
50
|
+
def ensure_partition_column(table, key)
|
|
51
|
+
return if table.columns.any? { |c| c.name == key.to_s }
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
table.column key, :string, null: false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Crea la tabla _default para capturar datos sin partición asignada.
|
|
57
|
+
def create_default_partition(table_name)
|
|
53
58
|
default_name = "#{table_name}_default"
|
|
54
59
|
execute <<-SQL.squish
|
|
55
60
|
CREATE TABLE IF NOT EXISTS #{default_name}
|
|
56
61
|
PARTITION OF #{table_name} DEFAULT;
|
|
57
62
|
SQL
|
|
58
63
|
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
64
|
end
|
|
65
65
|
end
|
|
66
66
|
end
|
data/lib/tenant_partition.rb
CHANGED
|
@@ -4,211 +4,113 @@ require "active_record"
|
|
|
4
4
|
require "active_model"
|
|
5
5
|
require "active_support/all"
|
|
6
6
|
|
|
7
|
-
# Carga de la versión y componentes internos
|
|
8
7
|
require_relative "tenant_partition/version"
|
|
9
8
|
require_relative "tenant_partition/configuration"
|
|
10
9
|
require_relative "tenant_partition/safety_guard"
|
|
11
10
|
require_relative "tenant_partition/base"
|
|
11
|
+
require_relative "tenant_partition/maintenance"
|
|
12
12
|
require_relative "tenant_partition/concerns/partitioned"
|
|
13
13
|
require_relative "tenant_partition/concerns/controller"
|
|
14
14
|
|
|
15
|
-
# Integración con Ruby on Rails
|
|
16
15
|
require_relative "tenant_partition/railtie" if defined?(Rails)
|
|
17
16
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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.
|
|
17
|
+
# Fachada principal para la gestión y orquestación de particionamiento en PostgreSQL.
|
|
18
|
+
# Centraliza la configuración, creación de particiones y mantenimiento.
|
|
25
19
|
module TenantPartition
|
|
26
|
-
# Error base para todas las excepciones de la gema.
|
|
27
20
|
class Error < StandardError; end
|
|
28
21
|
|
|
22
|
+
# Incorpora las funcionalidades de Auditoría y Limpieza.
|
|
23
|
+
extend Maintenance
|
|
24
|
+
|
|
29
25
|
class << self
|
|
30
|
-
# @return [TenantPartition::Configuration]
|
|
26
|
+
# @return [TenantPartition::Configuration] Objeto de configuración global.
|
|
31
27
|
attr_accessor :configuration
|
|
32
28
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
29
|
+
# Bloque de configuración e inicialización.
|
|
35
30
|
# @yieldparam [TenantPartition::Configuration] config
|
|
36
|
-
# @return [void]
|
|
37
31
|
def configure
|
|
38
32
|
self.configuration ||= Configuration.new
|
|
39
33
|
yield(configuration)
|
|
40
34
|
SafetyGuard.validate!
|
|
41
35
|
end
|
|
42
36
|
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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]
|
|
37
|
+
# Crea las particiones físicas para un tenant en todos los modelos registrados.
|
|
38
|
+
# @param partition_id [String, Integer] Identificador del tenant.
|
|
54
39
|
def create!(partition_id)
|
|
55
40
|
ensure_models_loaded!
|
|
56
|
-
|
|
57
41
|
log_info "CREATE", "Iniciando aprovisionamiento para ID: #{partition_id}"
|
|
58
42
|
|
|
59
43
|
partitionable_models.each do |model|
|
|
60
|
-
|
|
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
|
|
44
|
+
process_creation(model, partition_id)
|
|
66
45
|
end
|
|
67
46
|
end
|
|
68
47
|
|
|
69
|
-
# Elimina
|
|
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]
|
|
48
|
+
# Elimina irreversiblemente las particiones y datos de un tenant.
|
|
49
|
+
# @param partition_id [String, Integer] Identificador del tenant.
|
|
75
50
|
def destroy!(partition_id)
|
|
76
51
|
ensure_models_loaded!
|
|
77
|
-
|
|
78
52
|
log_info "DESTROY", "Eliminando infraestructura para ID: #{partition_id}"
|
|
79
53
|
|
|
80
54
|
partitionable_models.each do |model|
|
|
81
|
-
|
|
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
|
-
# TenantPartition.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
|
|
55
|
+
process_destruction(model, partition_id)
|
|
122
56
|
end
|
|
123
|
-
|
|
124
|
-
report
|
|
125
57
|
end
|
|
126
58
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
# 3. Mueve los datos atómicamente.
|
|
132
|
-
#
|
|
133
|
-
# @return [void]
|
|
134
|
-
def cleanup!
|
|
59
|
+
# Verifica si existe infraestructura creada para un tenant.
|
|
60
|
+
# @param partition_id [String, Integer] Identificador del tenant.
|
|
61
|
+
# @return [Boolean] true si existe al menos una tabla particionada.
|
|
62
|
+
def exists?(partition_id)
|
|
135
63
|
ensure_models_loaded!
|
|
136
|
-
|
|
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 TenantPartition::Base.
|
|
167
|
-
# @return [Array<Class>]
|
|
168
|
-
def partitionable_models
|
|
169
|
-
ObjectSpace.each_object(Class).select { |klass| klass < TenantPartition::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
|
|
64
|
+
partitionable_models.any? { |model| model.exists?(partition_id) }
|
|
178
65
|
end
|
|
179
66
|
|
|
180
|
-
#
|
|
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
|
|
67
|
+
# --- Shared Helpers (Accesibles por Maintenance) ---
|
|
188
68
|
|
|
189
|
-
#
|
|
190
|
-
# Vital en modo Development donde la carga es perezosa (Lazy Loading).
|
|
69
|
+
# Fuerza la carga de los modelos de partición en entornos con Lazy Loading (Dev).
|
|
191
70
|
def ensure_models_loaded!
|
|
192
71
|
return unless defined?(Rails)
|
|
193
72
|
|
|
194
73
|
partition_dir = Rails.root.join("app/models/partition")
|
|
195
74
|
return unless Dir.exist?(partition_dir)
|
|
196
75
|
|
|
197
|
-
# Forzamos la carga de todos los archivos en app/models/partition/
|
|
198
76
|
Dir[partition_dir.join("**/*.rb")].each { |file| require_dependency file }
|
|
199
77
|
end
|
|
200
78
|
|
|
201
|
-
#
|
|
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
|
+
|
|
202
87
|
def log_info(tag, msg) = logger&.info(format_log(tag, msg))
|
|
203
88
|
def log_warn(tag, msg) = logger&.warn(format_log(tag, msg))
|
|
204
89
|
def log_error(tag, msg) = logger&.error(format_log(tag, msg))
|
|
90
|
+
def format_log(tag, msg) = "[TenantPartition] [#{tag}] #{msg}"
|
|
91
|
+
def logger = (defined?(Rails) ? Rails.logger : Logger.new($stdout))
|
|
205
92
|
|
|
206
|
-
|
|
207
|
-
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def process_creation(model, partition_id)
|
|
96
|
+
if model.exists?(partition_id)
|
|
97
|
+
log_info "SKIP", "#{model.name}: La partición ya existe."
|
|
98
|
+
else
|
|
99
|
+
model.create(partition_id)
|
|
100
|
+
log_info "OK", "#{model.name}: Partición creada exitosamente."
|
|
101
|
+
end
|
|
208
102
|
end
|
|
209
103
|
|
|
210
|
-
def
|
|
211
|
-
|
|
104
|
+
def process_destruction(model, partition_id)
|
|
105
|
+
if model.exists?(partition_id)
|
|
106
|
+
if model.find(partition_id).destroy
|
|
107
|
+
log_info "DROP", "#{model.name}: Partición eliminada."
|
|
108
|
+
else
|
|
109
|
+
log_error "FAIL", "#{model.name}: No se pudo eliminar la partición."
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
log_info "SKIP", "#{model.name}: No existe partición para borrar."
|
|
113
|
+
end
|
|
212
114
|
end
|
|
213
115
|
end
|
|
214
116
|
end
|
metadata
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tenant_partition
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gabriel
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-02-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
14
|
+
name: activemodel
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
19
|
version: '7.1'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9.0'
|
|
20
23
|
type: :runtime
|
|
21
24
|
prerelease: false
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -24,13 +27,19 @@ dependencies:
|
|
|
24
27
|
- - ">="
|
|
25
28
|
- !ruby/object:Gem::Version
|
|
26
29
|
version: '7.1'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9.0'
|
|
27
33
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
34
|
+
name: activerecord
|
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
|
30
36
|
requirements:
|
|
31
37
|
- - ">="
|
|
32
38
|
- !ruby/object:Gem::Version
|
|
33
39
|
version: '7.1'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '9.0'
|
|
34
43
|
type: :runtime
|
|
35
44
|
prerelease: false
|
|
36
45
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -38,6 +47,9 @@ dependencies:
|
|
|
38
47
|
- - ">="
|
|
39
48
|
- !ruby/object:Gem::Version
|
|
40
49
|
version: '7.1'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '9.0'
|
|
41
53
|
description: Framework de infraestructura para Rails 7.1+ que automatiza el particionamiento
|
|
42
54
|
nativo (List Partitioning). Incluye soporte para Composite Primary Keys, orquestación
|
|
43
55
|
de tenants y migraciones zero-downtime.
|
|
@@ -51,11 +63,16 @@ files:
|
|
|
51
63
|
- LICENSE.txt
|
|
52
64
|
- README.md
|
|
53
65
|
- Rakefile
|
|
66
|
+
- lib/generators/tenant_partition/api_controller_generator.rb
|
|
67
|
+
- lib/generators/tenant_partition/templates/README
|
|
68
|
+
- lib/generators/tenant_partition/templates/tenant_partitions_controller.rb.erb
|
|
54
69
|
- lib/tenant_partition.rb
|
|
55
70
|
- lib/tenant_partition/base.rb
|
|
56
71
|
- lib/tenant_partition/concerns/controller.rb
|
|
72
|
+
- lib/tenant_partition/concerns/data_mover.rb
|
|
57
73
|
- lib/tenant_partition/concerns/partitioned.rb
|
|
58
74
|
- lib/tenant_partition/configuration.rb
|
|
75
|
+
- lib/tenant_partition/maintenance.rb
|
|
59
76
|
- lib/tenant_partition/railtie.rb
|
|
60
77
|
- lib/tenant_partition/safety_guard.rb
|
|
61
78
|
- lib/tenant_partition/schema/statements.rb
|