Multi-tenancy
Estratégia: Schema-per-Tenant
Cada empresa (tenant) possui um schema exclusivo no PostgreSQL. Isso garante isolamento total dos dados operacionais.
O isolamento tem um preço, e ele é contável
Schema por tenant não significa pool de conexões por tenant: desde 2026-09-09 cada nó tem um pool só, e o schema do tenant é aplicado quando a conexão é emprestada. O que uma instalação aguenta é função do número de pods e do tier do banco — quantas empresas existem não entra nessa conta. A tabela de dimensionamento e o que este desenho deliberadamente não resolve estão em Deploy → O envelope de operação.
PostgreSQL
├── public → Camada compartilhada e camada global
│ ├── tenants, users, process_roles
│ ├── robots, api_keys, webhooks, notifications, dmn_templates
│ ├── ai_agents, published_definitions, tenant_process_catalog
│ ├── attachments/vector_store da camada GLOBAL (tenant_id = '__global__')
│ └── TODAS as tabelas do Flowable (ACT_*) — ver ressalva abaixo
│
├── tenant_acme → Dados isolados da empresa "acme"
│ ├── cms_collections, cms_collection_fields, cms_records
│ ├── form_definitions, form_versions, form_data_sources
│ ├── attachments, tenant_variables, ai_task_failures
│ └── vector_store (embeddings do tenant)
│
└── tenant_globex → Dados isolados da empresa "globex"
└── ...Duas leituras erradas comuns deste desenho, ambas com consequência prática:
attachments, form_*, cms_*, tenant_variables e vector_store ficam no schema do tenant. São tabelas do Modelo A — quem isola é a conexão roteada, não uma cláusula WHERE. Consultá-las com um tenant_id no filtro não vai encontrar coluna nenhuma, e a migração que as cria mora em db/tenant/. As homônimas em public guardam apenas o que pertence à camada global: anexos e embeddings de um Global Process Template, que todos os tenants assinantes enxergam.
Só attachments, tenant_variables e vector_store têm homônima em public
form_definitions, form_versions e form_data_sources também existiam em public, vazias e sem ninguém usando — e era essa homônima vazia que transformava um erro de roteamento em lista vazia: a consulta encontrava a tabela, não encontrava as linhas, e o processo seguia como se o dado não existisse. Desde a V0105 elas se chamam *_legacy_unused, e a mesma leitura errada estoura com relation does not exist em vez de responder vazio.
As três que ficam são intencionais — a camada global grava nelas de verdade — então ali o erro de roteamento continua silencioso por construção, e quem o detecta é a TenantRoutingProbe.
As tabelas ACT_* do Flowable ficam só em public
São 53 tabelas ACT_*, todas em public, nenhuma em schema de tenant — inclusive as de runtime e de histórico. O isolamento do Flowable aqui é por discriminador (tenant_id_), não por schema.
A consequência é séria para quem escreve consulta: a escrita preenche o discriminador sozinha, mas a leitura só é isolada se a query disser. Um createProcessInstanceQuery() sem .processInstanceTenantId(...) devolve as instâncias de todos os tenants. Use a fachada TenantScopedFlowable, que já entrega a query filtrada. O detalhamento está em specs/core/flowable-tenant-isolation.md.
Quando a consulta não couber na fachada, o filtro vai na própria query, com TenantContext.requireCurrentTenant() — que recusa a consulta se não houver tenant no contexto, em vez de deixá-la rodar aberta. Uma varredura de build (FlowableQueryScopeTest) quebra em qualquer consulta cujo trecho não carregue o filtro do próprio tipo dela — taskTenantId para tarefa, processDefinitionTenantId para definição, e assim por diante; as poucas que são deliberadamente amplas ficam declaradas com o motivo, arquivo e linha.
Duas tabelas não têm o discriminador: act_hi_varinst e act_ru_variable, as de variáveis. Não há filtro de tenant a acrescentar — a API do Flowable 7.2.0 não oferece um — então a defesa é o id que o chamador já conferiu (processInstanceId, caseInstanceId, taskId), e a varredura exige esse estreitamento declarado. A identidade (act_id_group, act_id_user) é global da plataforma pelo mesmo motivo: dois tenants que pedem o mesmo papel compartilham um grupo do IDM.
O isolamento aqui é de dados, não de desempenho. Um tenant com histórico muito maior que os outros aumenta o tempo de consulta de todos, porque a varredura é a mesma tabela. E nenhuma cópia de um tenant só sai daqui: pg_dump -n tenant_<slug> não leva nada de public, então instâncias em andamento, tarefas, jobs e histórico ficam de fora de um export por tenant.
Componentes de roteamento
TenantFilter
O tenant da sessão é um claim assinado dentro do access token, obtido em POST /api/auth/select-tenant. O TenantFilter lê esse claim e armazena o tenant no TenantContext (ThreadLocal).
Enviar X-Tenant-ID responde 400. Um header é escolhido pelo cliente, e conferi-lo contra a lista de tenants ativos não impede que um usuário de um tenant peça dados de outro; o claim assinado tira essa escolha do cliente, porque alterá-lo invalida o token inteiro. Chave de API resolve o tenant a partir da própria credencial — o mesmo princípio.
Nenhum header escolhe tenant para ninguém, nem para SUPER_ADMIN. As telas de plataforma que agem sobre um tenant específico — atribuir papéis de processo, por exemplo — nomeiam o alvo na própria rota: GET /api/admin/tenants/{id}/definitions e GET /api/admin/tenants/{id}/process-roles, ambas exigindo SUPER_ADMIN.
TenantAwareDataSource
AbstractRoutingDataSource que usa o valor do TenantContext para rotear cada conexão JDBC para o schema correto. Sem tenant no contexto, roteia para o datasource compartilhado. Com um tenant que não tem conexão utilizável — hoje, só um tenant cuja migração falhou —, recusa a requisição (HTTP 503) em vez de cair no compartilhado.
Um pool só, com o schema aplicado por empréstimo
Não existe pool por tenant. Cada nó tem um pool HikariCP (spring.datasource.hikari.maximum-pool-size), e o que o tenant recebe é uma visão dele: SchemaBoundDataSource aponta a conexão emprestada para tenant_<slug> com set_config('search_path', ?, false) e limpa a sessão quando ela volta (RESET ALL, CLOSE ALL, UNLISTEN *, locks de aviso, DISCARD SEQUENCES/TEMP) — uma ida e volta ao servidor em cada ponta, e o schema de uma requisição nunca alcança a seguinte. O schema é migrado na primeira requisição daquele tenant, não no boot.
Até 2026-09-09 cada tenant tinha um pool dedicado, e o registro tinha um teto com despejo por LRU. A remoção foi motivada por uma medição: com 22 tenants contra um teto de 20 pools houve 8 despejos e uma requisição morreu com SocketException: Socket closed — fechar o pool despejado fecha conexão que uma requisição em curso está segurando. Ver specs/core/connection-pool-budget.md.
Ciclo de vida do tenant
CREATING → ACTIVE ⇄ SUSPENDED → DEACTIVATED → DROPPED| Estado | Aceita requisições? | Dados preservados? |
|---|---|---|
| CREATING | Não | Sim |
| ACTIVE | Sim | Sim |
| SUSPENDED | Não | Sim |
| DEACTIVATED | Não | Sim |
| DROPPED | Não | Não — schema removido |
Provisionamento assíncrono
Quando um tenant é criado via POST /api/admin/tenants, o provisionamento ocorre em background:
1. Tenant criado com status CREATING
2. @Async: CREATE SCHEMA tenant_<slug>
3. Flyway.migrate() com scripts de db/tenant/, pelo pool compartilhado
4. Seed dos templates DMN (autoProvision) e dos Global Process Templates
5. Status → ACTIVENão há passo de bootstrap do Flowable: as tabelas ACT_* já existem em public e são compartilhadas por todos os tenants.
Em caso de falha, o status vai para FAILED com a mensagem de erro registrada.
Definições BPMN/CMMN — na mesma tabela, marcadas pelo tenant
Processos (BPMN) e Casos (CMMN) são publicados nas tabelas ACT_* de public, como tudo do Flowable — mas cada publicação carrega o slug do tenant (createDeployment().tenantId(slug)). Um Template Global não é uma definição compartilhada: ele é replicado para cada tenant que assina, cada cópia com o seu próprio tenant.
Desde 2026-09-03 toda leitura de definição respeita esse marcador, inclusive as duas que ainda não respeitavam: iniciar uma instância pela chave e desenhar o monitor BPMN. Duas empresas podem ter um processo com a mesma chave sem que uma inicie a versão da outra.
As instâncias vivem nas mesmas tabelas ACT_* de public, marcadas com o mesmo discriminador tenant_id_. O isolamento do estado de execução é lógico, não estrutural — veja a ressalva na seção anterior.
DMN — escopo por tenant
Cada empresa customiza suas regras de negócio sem afetar outras: uma tabela de decisão é implantada com o tenant corrente e só é resolvida para ele. O isolamento vem do mesmo discriminador tenant_id_ das demais tabelas Flowable, e não de um schema separado.
Migrações Flyway
| Conjunto | Caminho | Executado quando |
|---|---|---|
| Shared | db/migration/ | No start, no schema public — e só nos papéis de nó que executam job (all e worker) |
| Per-tenant | db/tenant/ | Provisionamento, primeira requisição do tenant em cada nó, e varredura de fundo — nunca no start |
O caminho é db/tenant/, não db/migration/tenant/
O Flyway compartilhado está configurado com locations: classpath:db/migration. Qualquer arquivo aninhado ali dentro seria varrido por ele e aplicado também ao schema public — que não é o que uma migração de tenant deve fazer.
Ao criar um novo arquivo em db/tenant/, ele é aplicado a todos os tenants ativos sem ninguém migrar nada à mão — mas não no boot, e a diferença importa para quem faz o deploy. É por isso que a linha "per-tenant" da tabela acima não menciona o start.
Desde 2026-08-28 nenhum nó migra tenant nenhum ao subir: o tempo de boot deixou de crescer com a quantidade de tenants, que é o caminho por onde todo release passa. No lugar, três coisas:
| Quando | O que roda |
|---|---|
| Ao criar o tenant | TenantProvisioningService cria o schema, migra e só então marca ACTIVE — um tenant nunca fica ativo sem schema |
| Na primeira requisição daquele tenant em cada nó | viewFor migra o schema antes de entregar a primeira conexão daquele tenant, então o primeiro request depois de um deploy é o que aplica a migração naquele nó |
| Em segundo plano | TenantMigrationSweepJob, com lease, concorrência limitada e prazo, para o tenant que não recebe tráfego |
Um tenant cuja migração falha é marcado degradado e tentado uma vez por boot no caminho da requisição, nunca uma vez por requisição — só a varredura insiste. Sem essa trava, um schema quebrado viraria um Flyway por chamada HTTP. Ver specs/core/multi-tenancy.md.
Quando a migração de um tenant falha
Cada tentativa registra um resultado por tenant — na primeira requisição daquele tenant, ou na varredura. Procure a linha outcome= nos logs:
outcome | Nível | Significa |
|---|---|---|
migrated | INFO | schema em dia |
migration-failed | ERROR | Flyway falhou; o tenant é marcado degradado |
São só esses dois desde que o pool por tenant saiu: não existe mais um passo de abrir pool para falhar por conta própria.
Duas garantias:
Uma requisição de tenant nunca é servida pelo schema compartilhado. Se o tenant não tem conexão utilizável — hoje, só um tenant cuja migração falhou —, a requisição é recusada com HTTP 503 e nada é lido nem gravado. Atendê-la contra o public faria dois tenants degradados ao mesmo tempo compartilharem as mesmas tabelas.
O schema só muda por migração. ddl-auto é none no padrão da aplicação, e não só nos perfis dev, staging e prod. Com update, o Hibernate adiciona colunas novas mas nunca converte o tipo de uma coluna existente, e uma migração aplicada pela metade passaria por bem-sucedida — pior ainda, ele recria no boot a tabela que uma migração acabou de aposentar e cria em public a tabela que só deveria existir por tenant.
Orçamento de conexões
Cada instância da aplicação mantém um pool e uma conexão para o barramento de cluster. Uma conta só, e ela não cresce com o número de clientes:
demanda da frota (hikari.maximum-pool-size + 1) × pods
= (10 + 1) × pods com os padrõesDimensione o max_connections do servidor contra ela. Quando o servidor acaba, o sintoma é uma requisição falhando ao pegar conexão, com remaining connection slots are reserved no log.
Multiplique por backend.replicaCount antes de subir réplica — é o único fator que existe. A aplicação imprime a conta em toda subida, com o papel do nó, e recusa subir com APP_DATABASE_BUDGET_ENFORCE=true. APP_DATABASE_EXPECTED_TENANTS continua saindo na linha para mostrar que não a move. Ver specs/core/connection-pool-budget.md.
Até 2026-09-09 havia um pool por tenant, e a conta era (hikari.maximum-pool-size + app.tenant.pool.maximum-pool-size × tenantsAtivos + 1) × pods — o cliente novo era o evento que estourava o tier. Não é mais.
Dimensionando o pool e o teto por tenant
A etapa de IA deixou de segurar a conexão do banco enquanto espera o provedor: ela reserva o passo, devolve a thread e a transação, e o motor a retoma quando a resposta chega (BPMN em 2026-09-09, CMMN em 2026-09-10). O que ainda ocupa o pool é o trabalho curto em volta da chamada — ler o agente, buscar contexto no RAG, gravar o resultado, descontar a cota. O pool continua sendo o teto de operações simultâneas de um tenant, e com um pool pequeno basta a requisição seguinte chegar antes de a anterior terminar para a fila estourar.
| Variável de ambiente | Padrão | Para que serve |
|---|---|---|
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE | 10 | O único pool do nó. É ele que o orçamento de conexões conta. Tem piso: ver abaixo |
TENANT_POOL_MAX_SIZE | 7 em all e worker, 3 em api | Quantas operações simultâneas um tenant suporta — um teto dentro do pool acima, não um pool. O padrão vem de APP_NODE_ROLE; um valor explícito ganha dele. Tem o mesmo piso |
SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE | 1 | Quantas conexões ficam de pé com o nó ocioso |
SPRING_DATASOURCE_HIKARI_IDLE_TIMEOUT | 30000 | Em quanto tempo a conexão ociosa volta para o servidor |
SPRING_DATASOURCE_HIKARI_MAX_LIFETIME | 1800000 | Reciclagem antes do corte de ociosidade da rede |
O 7 não vem do tier do banco. Ele é o menor valor que a invariante da cota de IA permite: a dedução roda em REQUIRES_NEW, que suspende a transação do job sem devolver a conexão dela e pede uma segunda, então cada passo segura duas. FLOWABLE_EXECUTOR_MAX_SIZE × 2 + 1 = 3 × 2 + 1 = 7, com o + 1 reservado ao tráfego HTTP. Era 8 até 2026-09-03; passou a 7 pela derivação, não por pressão de densidade. O mesmo piso vale para o pool compartilhado desde 2026-09-09, porque é dele que as duas conexões saem agora: o guard confere o menor entre os dois, e baixar qualquer um sozinho é recusado na renderização do chart. Ver specs/ai/quota-transaction-boundary.md.
Subir o teto não é o conserto definitivo
Aumentar o pool alivia o sintoma e continua limitado pelo orçamento de conexões acima. E o teto por tenant é máximo, não reserva: um tenant parado não tem conexão guardada para ele, então uma rajada de outro pode fazê-lo esperar. A parte que era estrutural — a tarefa de IA não segurar conexão nenhuma enquanto espera a rede — foi resolvida em 2026-09-09 (BPMN) e 2026-09-10 (CMMN): a etapa reserva, devolve a transação e é retomada pelo motor quando a resposta chega. Ver Arquitetura → Concorrência das etapas de IA.