Skip to content
Menu

Documentation

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_MODULES must load the module, or the command stops with Module not found: ….
  • --from and --to name 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

  1. Start the module's service on its own DATABASE_URL, with the same CACHE_URL, event bus and JWT settings as the other services.
  2. Remove the module from the monolith's SANDLOOM_MODULES.
  3. Deploy the bindings and gateway routes that sandloom add deploy wrote 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.py covers the fence, resume, refusals, drop-source, unfence and the group transfer. The full scenario, a monolith split into three services with its data, runs with SANDLOOM_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.