Skip to content

Multi-tenancy

Strategy: schema-per-tenant

Each company (tenant) owns an exclusive schema in PostgreSQL. That is what isolates the operational data.

The isolation has a price, and it is countable

Schema-per-tenant does not mean a connection pool per tenant: since 2026-09-09 each node has a single pool, and the tenant's schema is applied when a connection is borrowed. What one installation holds is a function of the number of pods and of the database tier — how many companies exist does not enter that sum. The sizing table, and what this design deliberately does not solve, are in Deploy → The operating envelope.

PostgreSQL
├── public              → Shared layer and global layer
│   ├── tenants, users, process_roles
│   ├── robots, api_keys, webhooks, notifications, dmn_templates
│   ├── ai_agents, published_definitions, tenant_process_catalog
│   ├── attachments/vector_store of the GLOBAL layer (tenant_id = '__global__')
│   └── ALL Flowable tables (ACT_*) — see the caveat below

├── tenant_acme         → Isolated data for company "acme"
│   ├── cms_collections, cms_collection_fields, cms_records
│   ├── form_definitions, form_versions, form_data_sources
│   ├── attachments, tenant_variables, ai_task_failures
│   └── vector_store (the tenant's embeddings)

└── tenant_globex       → Isolated data for company "globex"
    └── ...

Two common misreadings of this layout, both with practical consequences:

attachments, form_*, cms_*, tenant_variables and vector_store live in the tenant schema. They are Model A tables — the routed connection isolates them, not a WHERE clause. Querying them with a tenant_id filter finds no such column, and the migration that creates them lives in db/tenant/. Their namesakes in public hold only what belongs to the global layer: attachments and embeddings of a Global Process Template, visible to every subscribing tenant.

Only attachments, tenant_variables and vector_store have a namesake in public

form_definitions, form_versions and form_data_sources used to exist in public too — empty and used by nobody. That empty namesake is what turned a routing error into an empty list: the query found the table, found no rows, and the process carried on as if the data did not exist. Since V0105 they are named *_legacy_unused, and the same misrouted read now fails with relation does not exist instead of answering empty.

The three that stay are deliberate — the global layer really does write to them — so a routing error there is still silent by construction, and TenantRoutingProbe is what detects it.

Flowable's ACT_* tables live only in public

There are 53 ACT_* tables, all in public, none in any tenant schema — runtime and history included. Flowable's isolation here is by discriminator (tenant_id_), not by schema.

The consequence is serious for anyone writing a query: writes fill the discriminator on their own, but a read is only isolated if the query says so. A createProcessInstanceQuery() without .processInstanceTenantId(...) returns instances from every tenant. Use the TenantScopedFlowable facade, which hands back the query already filtered. The full account is in specs/core/flowable-tenant-isolation.md.

When a query does not fit the facade, the filter goes on the query itself with TenantContext.requireCurrentTenant() — which refuses the query when there is no tenant in context instead of letting it run open. A build scan (FlowableQueryScopeTest) fails on any query whose statement does not carry the filter of its own typetaskTenantId for a task, processDefinitionTenantId for a definition, and so on; the few that are deliberately broad are declared with the reason, file and line.

Two tables have no discriminator at all: act_hi_varinst and act_ru_variable, the variable tables. There is no tenant filter to add — Flowable 7.2.0 offers none — so the defence is the id the caller already checked (processInstanceId, caseInstanceId, taskId), and the scan demands that narrowing, declared. Identity (act_id_group, act_id_user) is platform-wide for the same reason: two tenants asking for the same role share one IDM group.

The isolation here is of data, not of performance. A tenant whose history is far larger than the others raises query time for all of them, because it is the same table being scanned. And no copy of a single tenant comes out of here: pg_dump -n tenant_<slug> takes nothing from public, so running instances, tasks, jobs and history are left out of a per-tenant export.

Routing components

TenantFilter

The session's tenant is a signed claim inside the access token, obtained from POST /api/auth/select-tenant. TenantFilter reads that claim and stores the tenant in TenantContext (ThreadLocal).

Sending X-Tenant-ID answers 400. A header is the client's choice, and checking it against the list of active tenants would not stop a user of one tenant from asking for another's data; the signed claim takes that choice away from the client, because altering it invalidates the whole token. API keys resolve their tenant from the credential itself — the same principle.

No header selects a tenant for anyone, not even SUPER_ADMIN. The platform screens that act on one specific tenant — assigning process roles, for instance — name their target in the route itself: GET /api/admin/tenants/{id}/definitions and GET /api/admin/tenants/{id}/process-roles, both requiring SUPER_ADMIN.

TenantAwareDataSource

An AbstractRoutingDataSource that uses the value in TenantContext to route each JDBC connection to the right schema. With no tenant in the context it routes to the shared datasource. With a tenant that has no usable connection — today, only a tenant whose migrations failed — it refuses the request (HTTP 503) instead of falling back to the shared one.

One pool, with the schema applied per checkout

There is no pool per tenant. Each node has one HikariCP pool (spring.datasource.hikari.maximum-pool-size), and what a tenant gets is a view of it: SchemaBoundDataSource points the borrowed connection at tenant_<slug> with set_config('search_path', ?, false) and resets the session when it goes back (RESET ALL, CLOSE ALL, UNLISTEN *, advisory locks, DISCARD SEQUENCES/TEMP) — one round trip at each end, and one request's schema never reaches the next. The schema is migrated on that tenant's first request, not at boot.

Until 2026-09-09 each tenant had a dedicated pool and the registry had an LRU cap. The removal was driven by a measurement: with 22 tenants against a cap of 20 pools there were 8 evictions and one request died with SocketException: Socket closed — closing an evicted pool closes connections a running request is holding. See specs/core/connection-pool-budget.md.

Sizing the pool and the per-tenant ceiling

The AI step no longer holds a database connection while it waits on the provider: it reserves the step, gives the thread and the transaction back, and the engine resumes it when the answer arrives (BPMN on 2026-09-09, CMMN on 2026-09-10). What still occupies the pool is the short work around the call — reading the agent, fetching RAG context, writing the result, debiting the quota. The pool is still the ceiling on one tenant's concurrent operations, and with a small pool the next request only has to arrive before the previous one finishes for the queue to overflow.

Environment variableDefaultWhat it controls
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE10The node's only pool. It is what the connection budget counts. It has a floor: see below
TENANT_POOL_MAX_SIZE7 on all and worker, 3 on apiConcurrent operations one tenant can sustain — a ceiling inside the pool above, not a pool. The default comes from APP_NODE_ROLE; an explicit value wins over it. Same floor
SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE1How many connections stand up while the node is idle
SPRING_DATASOURCE_HIKARI_IDLE_TIMEOUT30000How quickly an idle connection goes back to the server
SPRING_DATASOURCE_HIKARI_MAX_LIFETIME1800000Recycles before the network's idle cut

The 7 does not come from the database tier. It is the smallest value the AI quota invariant allows: the deduction runs under REQUIRES_NEW, which suspends the job's transaction without releasing its connection and takes a second one, so every step holds two. FLOWABLE_EXECUTOR_MAX_SIZE × 2 + 1 = 3 × 2 + 1 = 7, with the + 1 reserved for the same tenant's HTTP traffic. It was 8 until 2026-09-03; it became 7 by that derivation, not by density pressure. Since 2026-09-09 the same floor applies to the shared pool, because that is where the two connections now come from: the guard checks the smaller of the two, and lowering either on its own is refused at chart render time. See specs/ai/quota-transaction-boundary.md.

Raising the ceiling is not the real fix

A bigger pool relieves the symptom and is still bounded by the connection budget above. And the per-tenant ceiling is a maximum, not a reservation: an idle tenant has no connection held for it, so another tenant's burst can make it wait. The part that was structural — the AI task holding no connection at all while it waits on the network — was fixed on 2026-09-09 (BPMN) and 2026-09-10 (CMMN): the step reserves, gives the transaction back and is resumed by the engine when the answer arrives. See Architecture → AI step concurrency.

Tenant lifecycle

CREATING → ACTIVE ⇄ SUSPENDED → DEACTIVATED → DROPPED
StateAccepts requests?Data preserved?
CREATINGNoYes
ACTIVEYesYes
SUSPENDEDNoYes
DEACTIVATEDNoYes
DROPPEDNoNo — schema dropped

Asynchronous provisioning

When a tenant is created through POST /api/admin/tenants, provisioning runs in the background:

1. Tenant created with status CREATING
2. @Async: CREATE SCHEMA tenant_<slug>
3. Flyway.migrate() with the scripts from db/tenant/, through the shared pool
4. Seed of the DMN templates (autoProvision) and the Global Process Templates
5. Status → ACTIVE

There is no Flowable bootstrap step: the ACT_* tables already exist in public and are shared by every tenant.

On failure the status becomes FAILED with the error message recorded.

BPMN/CMMN definitions — same table, tagged by tenant

Processes (BPMN) and Cases (CMMN) are deployed into the ACT_* tables in public, like everything else in Flowable — but each deployment carries the tenant slug (createDeployment().tenantId(slug)). A Global Template is not one shared definition: it is replicated into every subscribing tenant, each copy tagged with its own tenant.

Since 2026-09-03 every definition read honours that tag, including the two that did not: starting an instance by key, and drawing the BPMN monitor. Two companies can hold a process under the same key without one starting the other's version.

Instances live in those same ACT_* tables in public, tagged with the same tenant_id_ discriminator. Execution state is isolated logically, not structurally — see the caveat in the previous section.

DMN — per-tenant scope

Each company customises its business rules without affecting anyone else: a decision table is deployed under the current tenant and only resolves for it. The isolation comes from the same tenant_id_ discriminator as the other Flowable tables, not from a separate schema.

Flyway migrations

SetPathRuns when
Shareddb/migration/At startup, against the public schema — and only on the node roles that execute jobs (all and worker)
Per-tenantdb/tenant/At provisioning, on that tenant's first request on each node, and on the background sweep — never at startup

The path is db/tenant/, not db/migration/tenant/

The shared Flyway is configured with locations: classpath:db/migration. Anything nested in there would be swept up by it and applied to the public schema as well — which is not what a tenant migration is supposed to do.

Once you add a new file to db/tenant/, it reaches every active tenant with nobody migrating anything by hand — but not at boot, and the difference matters to whoever runs the deploy. That is why the "per-tenant" row above does not mention startup.

Since 2026-08-28 no node migrates any tenant on startup: boot time stopped growing with the tenant count, which is the path every release takes. Three things happen instead:

WhenWhat runs
At tenant creationTenantProvisioningService creates the schema, migrates it, and only then marks it ACTIVE — a tenant is never active without a schema
On that tenant's first request on each nodeviewFor migrates the schema before handing out that tenant's first connection, so the first request after a deploy is what applies the migration on that node
In the backgroundTenantMigrationSweepJob, under a lease, with bounded concurrency and a deadline, for the tenant that gets no traffic

A tenant whose migration fails is flagged degraded and attempted once per boot on the request path, never once per request — only the sweep retries. Without that guard, one broken schema would turn into a Flyway run per HTTP call. See specs/core/multi-tenancy.md.

When a tenant migration fails

Each attempt logs one outcome per tenant — on that tenant's first request, or on the sweep. Look for the outcome= field:

outcomeLevelMeans
migratedINFOschema up to date
migration-failedERRORFlyway failed; the tenant is flagged degraded

Only those two since the per-tenant pool went away: there is no longer a pool-opening step that can fail on its own.

Two guarantees:

A tenant request is never served by the shared schema. If the tenant has no usable connection — today, only a tenant whose migrations failed — the request is refused with HTTP 503 and nothing is read or written. Serving it against public would have two degraded tenants sharing the same tables.

The schema only changes through a migration. ddl-auto is none in the application default, not merely in the dev, staging and prod profiles. Under update, Hibernate adds new columns but never converts the type of an existing one, so a half-applied migration would pass for a successful one — worse, it recreates at boot the table a migration has just retired, and creates in public the table that should only exist per tenant.

Connection budget

Every application instance keeps one pool and one connection for the cluster bus. One sum, and it does not grow with the number of customers:

the fleet's demand    (hikari.maximum-pool-size + 1) × pods
                      = (10 + 1) × pods with the defaults

Size the server's max_connections against it. When the server runs out, the symptom is a request failing to get a connection, with remaining connection slots are reserved in the log.

Multiply by backend.replicaCount before adding a replica — it is the only factor there is. The application prints the sum on every startup, with the node role, and refuses to boot under APP_DATABASE_BUDGET_ENFORCE=true. APP_DATABASE_EXPECTED_TENANTS still appears on that line, to show that it does not move the answer. See specs/core/connection-pool-budget.md.

Until 2026-09-09 there was a pool per tenant, and the sum was (hikari.maximum-pool-size + app.tenant.pool.maximum-pool-size × activeTenants + 1) × pods — the next customer was the event that exhausted the tier. It is not any more.

Flowi Agentic — Plataforma de Gestão de Processos com IA