Move a module's data to its own database¶
Goal: one module's existing rows live in the database of its own
service, and the shared database no longer accepts writes to them.
Split into services moves the module's code to the
service by configuration; sandloom migrate extract-module moves its data.
Use it when a module that already holds data in the shared (monolith) database gets its own service and database.
Do not use it for a new deployment with no data: the service's
migrate-<service> one-shot creates its empty tables. It does not move data
while the module keeps writing. The move is offline: the module's writers
stop for a maintenance window while the other modules keep serving.
Prerequisites¶
- Run every command with the monolith's configuration. Its
SANDLOOM_MODULESmust load the module, or the command stops withModule not found: …. --fromand--toname two different PostgreSQL databases, each as a configured instance name (default) or a URL. The target database exists.- The module's chain is at head on the source
(
sandloom migrate upgrade-module <module> --apply). - No foreign key leads from the module's tables to another module's tables.
- When a module table has row-level security, the role on both sides is a
superuser or has
BYPASSRLS. Otherwise the copy would see one tenant's rows, and the preflight refuses.
Steps¶
1. Read the plan¶
The default is a dry run that writes nothing:
sandloom migrate extract-module tenancy --from default --to "$TENANCY_DATABASE_URL"
On a monolith holding one organization and three members, it printed:
extract-module sandloom_tenancy [extract] DRY-RUN (pass --apply to execute)
source: postgresql+psycopg2://***@127.0.0.1:25901/mono
target: postgresql+psycopg2://***@127.0.0.1:25901/tenancy
source fenced (read-only): no
target manifest complete: no
tables (FK order):
tenancy_organizations source=1 target=- pending
tenancy_invitations source=0 target=- pending
tenancy_member_versions source=0 target=- pending
tenancy_members source=3 target=- pending
outbox: pending=0 dead=0 (module channels, to hand off); already handed off=0
consumer_inbox claims to copy: 0
audit_log rows to copy: 0
contract_command_log records to copy: 0
saga instances to copy: 0
http_idempotency_log records to copy: 0
action: fence the source (module tables read-only, module outbox channels refuse inserts)
action: apply the core chain + the module chain to the target
action: copy + verify tables: ['tenancy_organizations', 'tenancy_invitations', 'tenancy_member_versions', 'tenancy_members']
action: set sequences, copy consumer_inbox/audit/command-log rows, hand off undelivered outbox rows, write the completion manifest
streams group owners: none recorded for this module/database
A PROBLEM: line is a preflight refusal, and the command exits 1. Fix the
cause it names before the window starts.
2. Stop every writer of the module¶
Stop every process that loads the module:
- the app replicas;
- its worker and beat;
- the processes that run its consumers.
Until the cutover, nothing may write the module's tables or publish its events. The rest of the monolith may keep running.
3. Extract¶
Run the plan's command with --apply --i-have-stopped-writes:
sandloom migrate extract-module tenancy --from default --to "$TENANCY_DATABASE_URL" \
--apply --i-have-stopped-writes
Without --i-have-stopped-writes the command refuses:
extract-module refused/failed: refusing to extract without --i-have-stopped-writes: stop every process that writes the module (app, workers, beat, consumers) first; the fence then makes the source read-only
The run fences the source first. Triggers make the module's tables read-only and refuse new outbox rows on its channels. A write to a fenced table fails:
ERROR: sandloom: tenancy_organizations is read-only: module sandloom_tenancy is fenced for extraction to another database (sandloom migrate extract-module)
Then it migrates the target to head and copies, in this order:
| What | Which rows | After the copy |
|---|---|---|
| Module tables and their sequences | every table mapped under <package>.models, and every table named <module>_… |
each table verified by row count and checksum, recorded in a manifest in the target |
Undelivered events (outbox_messages) |
pending and dead rows on the module's channels | same ids in the target; marked delivered in the source, which never sends them again |
Dedupe records (consumer_inbox, contract_command_log, http_idempotency_log) |
the module's consumer claims, command results and stored responses | a redelivered event or a retried call after the move is still answered once |
Saga state (saga_instances, saga_steps) |
the module's sagas | the service continues each saga from where it stopped |
Audit rows (audit_log) |
actions in the module's namespace | the source keeps its copy |
| Streams consumer-group owners | groups the module owns on the source database | re-pointed to the target database |
Every step is idempotent. After a crash or a refusal, fix the cause and run the same command again: it skips the tables its manifest already holds. A target table with rows the tool did not write is refused, never overwritten.
A process that still writes the module makes the fence time out after
--lock-timeout-ms (5000 by default), with
could not fence the source within 5000 ms: … the module still has writers.
Stop that process and run the same command again.
The group owner is the database the services connect to. When they reach
the databases under other host names than the URLs you pass, add
--source-identity-url and --target-identity-url with the URLs the
services use.
4. Cut over¶
- Start the module's service on its own
DATABASE_URL, with the sameCACHE_URL, event bus and JWT settings as the other services. - Remove the module from the monolith's
SANDLOOM_MODULES. - Deploy the bindings and gateway routes that
sandloom add deploywrote for the service.
A monolith that still loads the module cannot consume its events after the
group transfer: claiming a moved group raises GroupOwnershipError.
Verify¶
Compare the target with the manifest at any time; it writes nothing:
sandloom migrate extract-module tenancy --from default --to "$TENANCY_DATABASE_URL" --verify
It prints the same report with [verify] and exits 0. A row count or
checksum that differs exits 1 with verification failed: ….
Roll back before the cutover¶
--unfence --apply drops the fence and makes the source writable again.
After a completed extraction the command refuses, because the new service may
already hold writes the source lacks:
PROBLEM: the extraction completed: the new service may already have taken writes that the source does not have. Unfencing makes the stale source writable again — pass --force only if the target never served traffic
Add --force only when the service never served traffic.
Clean up the source¶
Once the service has run cleanly, drop the module's tables from the shared database. Read the plan first, then apply it:
sandloom migrate extract-module tenancy --from default --to "$TENANCY_DATABASE_URL" --drop-source
sandloom migrate extract-module tenancy --from default --to "$TENANCY_DATABASE_URL" \
--drop-source --apply --i-have-stopped-writes
The cleanup requires the completed manifest and a fenced source that still
matches it, and refuses while undelivered module events remain in the
source. Then it drops the module's tables with alembic_version_<module>
and deletes the module's inbox claims, command records and saga instances.
The audit rows, the handed-off outbox rows and the fence stay in the shared database.
Next steps¶
- One window per module: online (zero-downtime) extraction is not implemented.
src/sandloom/core/tests/test_module_extraction.pycovers the fence, resume, refusals, drop-source, unfence and the group transfer. The full scenario, a monolith split into three services with its data, runs withSANDLOOM_EXTRACTION_E2E=1 uv run pytest tests/extraction --no-cov.- Every flag:
uv run sandloom migrate extract-module --help. The operator runbook with the same steps:deploy/README.md.