Routing¶
The proxy parses every statement with the Postgres parser (pg_query) and decides which organization it belongs to. Before the statement it sets lokadb.org in the cloud session, and Postgres prunes every partition but that organization's. A statement that touches only one organization therefore never reaches another customer's database.
How the organization is found¶
- Conditions:
=,IN (…)and= ANY(array)on the tenant column, the row key, or a foreign-key column (looked up through<table>__refs, which maps every key to its organization). Values may be literals or bound parameters, text or binary, scalars or arrays ('{1,2}',ARRAY[…],$1::bigint[]). - Inserts: the organization comes from
VALUESor fromUNNESTarrays (Djangobulk_create); for a child table, from its parent. - Joins: a value bound to one column binds every column equal to it:
d.project_id = p.id AND p.id = $1gives the organization throughd.project_id.ONconditions of inner joins count likeWHERE; those of outer joins do not. - Subqueries:
conversation_id IN (SELECT id FROM conversations WHERE account_id = $1)(Railswhere(conversation_id: account.conversations)) goes to that organization's database when both columns resolve through the same lookup. A correlated subquery (EXISTS (SELECT 1 FROM versions v WHERE v.document_id = d.id)) takes the organization of the outer query. INSERT … SELECTgets the organization when the lookup column receives a constant, a parameter or a source column with the same lookup, and theSELECTnames the organization.- If the tenant column is named (
organization_id = 1 AND id = 5), the id is not looked up: the policy already shows only that organization's rows, and writing another organization's key is refused by__refsand the parent trigger.
Values of the condition that belong to several organizations (organization_id IN (1, 2)) make a statement for all organizations, under the same policy as a statement without one.
Statements without an organization¶
Postgres would run such a statement in every partition, which means in the database of every moved organization: slowly, and with an error if any of those databases is down. The fanout policy in the proxy config decides, per database (that is, per service):
"fanout": {
"*": {"mode": "reject"},
"reports": {"mode": "allow"},
"app": {"mode": "reject", "allow": ["SELECT * FROM documents WHERE slug = $1 LIMIT $2"]}
}
mode |
What it does |
|---|---|
reject |
default (no entry, or no fanout at all): refused |
reject-writes |
INSERT, UPDATE, DELETE refused; reads run in every database |
log |
runs everywhere, with a warning in the proxy log |
allow |
runs everywhere |
allow lists statements that were reviewed and allowed under any mode — an admin report across organizations, a background job. They are compared by pg_query fingerprint: constants, parameters, list lengths and quoting do not matter.
A refused statement gets an ordinary Postgres error (0A000) raised by the server itself, so the transaction ends up exactly as after any error:
ERROR: lokadb: the statement does not name the organization of documents rows, so it would run in every organization's database (fanout policy "reject")
DETAIL: why: unknown_column documents.slug (=)
HINT: Restrict it by the organization column, id or a parent id (= or IN), or add it to "allow" of this database's fanout policy in the proxy config.
lokadb check -proxy-config applies the same policy in advance. The policy applies only after enable; before it, the proxy passes everything through.
Statements in which the proxy sees no LokaDB table¶
A statement that reads LokaDB tables in a way the analyzer did not recognize does not run under "all organizations". It runs:
- under the organization the application named (
lokadb.tenant, cooperative mode); - under all organizations only where that is explicitly allowed (
lokadb.tenant = '*'for a role inall_roles, policyalloworlog, a statement inallow); - otherwise under "no organization": it sees no rows of LokaDB tables, and writes to them are refused.
A gap in the analyzer therefore gives an empty answer, never another organization's rows. Known forms are recognized one by one: EXECUTE of an SQL PREPARE, functions over LokaDB tables in any schema, views through search_path, REFRESH MATERIALIZED VIEW, functions and views created through the proxy. Dynamic SQL inside functions and DO blocks is not recognized: it sees no rows unless the organization is named, and then only that organization's rows.
Why the organization was not found¶
For every part of a statement (SELECT, subquery, CTE, UPDATE, DELETE, INSERT) that reads or writes a LokaDB table without an organization, the analyzer names the reason — in the client error (DETAIL), in the proxy log, and in lokadb check:
kind |
Meaning | Example |
|---|---|---|
no_condition |
the table has no conditions at all | SELECT * FROM documents |
unknown_column |
a condition on a column with no organization lookup | WHERE slug = $1; WHERE conversation_id = $1 without a foreign key |
unsupported_form |
a form the analyzer does not read | id <> ANY($1), id = 1 OR id = 2, INSERT … SELECT without the organization, a view or function over LokaDB tables, a DO block |
unreadable_value |
the lookup column is compared with an expression, not a value | organization_id = lower($1)::bigint |
A statement the proxy's parser (PostgreSQL 17 grammar) cannot parse, and whose text names a LokaDB table, view or function, is a statement without an organization: the policy decides.
Declarations¶
The proxy looks up the organization only where a lookup exists: the tenant column, the key, a foreign key to a LokaDB table. Declarations add lookups the schema does not have. After enable, apply them with lokadb routing apply -db DSN -spec spec.json [-patch patch.json], without a new enable; proxies pick them up when they reload metadata.
Relations without a foreign key: routing.relations¶
Rails, Laravel without foreign() and many other applications do not declare foreign keys. Then the application's main query — a conversation feed WHERE "messages"."conversation_id" = $1 — has no organization. Declare the relation:
"routing": {"relations": [{"from": "messages.conversation_id", "to": "conversations.id"}]}
- Before registering it, LokaDB checks it on the data: how many rows with a value found a parent, and how many parents belong to another organization. Unless it is 100 % and 0, the relation is refused until the owner confirms it (
"confirmed": true). - The target is the key of a LokaDB table.
- A polymorphic reference (
<x>_idnext to<x>_type, like Railstaggable_type/taggable_id) points at one of several tables: its ids would find the organization in the wrong place. Such a relation is refused. - New rows are not checked: if the application writes a reference to a parent of another organization, queries by it will find the parent's organization.
Cloud tables that know the organization: routing.cloud_tables¶
A table can stay in the cloud and still know the organization of its rows (projects.account_id). A query that names the organization only through it (… JOIN projects p ON p.id = t.project_id WHERE p.id = $1) would otherwise go to every database.
"routing": {"cloud_tables": [{"table": "projects", "tenant_column": "account_id"}]}
A condition on projects.account_id then gives the organization directly; a condition on projects.id gives it through a lookup in the cloud. The declaration asserts that LokaDB rows linked to a cloud row belong to its organization: the owner must verify that on the data.
Unique keys: routing.keys¶
For queries by a column unique across the table (WHERE slug = $1, WHERE public_token = $1):
"routing": {"keys": [{"table": "documents", "column": "slug"}]}
A unique column that the application looks up without the organization (a webhook token, an order number, an external id) is the usual case for this declaration: check offers it first, with the fragment to paste, for every single-column unique index (unique_not_tenant_scoped), and -suggest proposes it for unique columns named like a token, a number or an external id. A composite unique key (provider, provider_payment_id) cannot be a key yet; if one of its columns is unique on the data (provider ids usually are), check says so: add a unique index on that column and declare it. LokaDB checks that values do not repeat, creates a mapping table lokadb.<table>__key__<column> (value → organization) kept by a trigger, and the proxy looks up the organization by value like by key. The mapping's primary key keeps the value unique across all organizations — which an index on a table with foreign partitions cannot do; a duplicate is refused by check, by enable and on insert. enable -prepare fills the mapping in advance, while the application writes.
Foreign keys to cloud tables after the move¶
Postgres keeps a foreign key from a LokaDB table to a cloud table (documents.project_id → projects) only on cloud partitions. For a moved organization LokaDB replaces it with two triggers: one on the organization's foreign partition (a row written through the cloud must reference an existing cloud row, locked FOR KEY SHARE; error 23503), and one on the cloud table (deleting a row or changing its key performs the foreign key's action — CASCADE, SET NULL, SET DEFAULT, RESTRICT, NO ACTION — in the moved organizations' databases too).
Where the referencing rows are searched:
- the cloud table is declared in
routing.cloud_tables— only in that row's organization; - the organizations table itself (
tenants,organizations: the table that the organization columns of the spec's tables reference by foreign key onto itsid) is a cloud table whose organization column is itsid, with no declaration: deleting a tenant, or changing its key, concerns that one organization, so the action runs in its database only, under any fanout policy.{"table": "tenants", "tenant_column": "id"}inrouting.cloud_tablessays the same; - otherwise in every moved organization, as a statement without an organization: under
logorallowthe action runs in every organization database (if one is down, the delete is refused); underreject-writesonlyRESTRICTandNO ACTIONpass; underreject(the default) the delete or key change is refused once any organization has moved.
A table shared by organizations (users, linked to them through tenant_users) has no column that holds the organization of its rows, so it cannot be declared: deleting a user, or SET NULL on its key, touches every moved organization that references it. Either allow that statement to run in every organization (fanout policy log or allow, or an allow-list entry for the reviewed DELETE in the proxy config), or delete softly in the application.
check names such tables in advance (fk_to_cloud_delete). The check is not deferred to COMMIT, even for DEFERRABLE keys, and writes made directly in the customer database are not checked.
Rows without an organization¶
A row without an organization — NULL in the tenant column, or a child whose parent is missing or itself without an organization — is accepted by enable and stays in the cloud in <table>__none. No move takes it. Statements without an organization see these rows as before enable; statements of an organization do not (they did not before either: organization_id = 1 does not match NULL). A child row's organization is taken from its parent on insert and never recomputed, so a new parent from another organization is refused.
Pausing one organization¶
Every statement on LokaDB tables takes a shared advisory lock of its organization in the same transaction; a statement not reduced to one organization takes the shared "all organizations" lock. move-org (and repoint, org-profile) takes both exclusively while it changes that organization: its open transactions finish, new ones wait, other organizations do not wait. Connections that bypass the proxy do not take the lock; a CHECK constraint on the cloud partition protects against their inserts during a move.