django-tenants-temporal
Tenant-aware Temporal workflows and activities for django-tenants.
If you have used tenant-schemas-celery, this is the Temporal equivalent — and there wasn't one, which is why this exists.
The mental model. The schema active when you start a workflow is written into a Temporal header. Every hop after that carries the header along and binds it to a contextvar. Before a sync activity's body runs, the schema is re-entered on that worker thread's connection. Your activities keep using the ORM exactly as they would inside a request, and no workflow signature ever mentions a tenant.
┌─ Django process ──────────────────────────────────┐
│ schema "acme" is active │
│ start_workflow() ──► header: schema = "acme" │
└──────────────────────────┬────────────────────────┘
│ Temporal server carries the header
▼
┌─ TenantWorker ────────────────────────────────────┐
│ TenantSchemaInterceptor installed │
│ │
│ Workflow (sandboxed — no Django, no ORM) │
│ header ──► contextvar │
│ │ │
│ │ child workflow · signal · query · │
│ │ update · continue-as-new │
│ │ each one re-stamped with "acme" │
│ ▼ │
│ Sync activity (worker thread) │
│ schema re-entered on this thread's connection │
│ your ORM calls — tenant already active │
└───────────────────────────────────────────────────┘
Install
pip install django-tenants-temporal
Requires django-tenants>=3.5, django>=4.2, temporalio>=1.9.
Quick start
1. Client — add the interceptor wherever you build a Client:
from django_tenants_temporal import connect
client = await connect("localhost:7233", namespace="default")
Or on a Client you build yourself:
from temporalio.client import Client
from django_tenants_temporal import TenantSchemaInterceptor
client = await Client.connect("localhost:7233", interceptors=[TenantSchemaInterceptor()])
2. Worker — TenantWorker wraps your activities and installs the interceptor:
from concurrent.futures import ThreadPoolExecutor
from django_tenants_temporal import TenantWorker, autodiscover
workflows, activities = autodiscover()
worker = TenantWorker(
client,
task_queue="default",
workflows=workflows,
activities=activities,
activity_executor=ThreadPoolExecutor(max_workers=8),
)
Or skip the wiring entirely:
python manage.py run_temporal_worker --task-queue default
autodiscover() walks INSTALLED_APPS for <app>/workflows/ and <app>/activities/
packages, the way Celery's autodiscover_tasks walks for tasks.py.
3. Dispatch — from ordinary synchronous Django code:
from django_tenants_temporal import start_workflow
def my_view(request):
start_workflow(SendRemindersWorkflow, invoice.id, task_queue="default")
The schema is captured from connection.schema_name on the request thread. There is no
step 4 — activities need no import from this package.
How this differs from tenant-schemas-celery
tenant-schemas-celery smuggles the schema through the task's arguments, because that is
what Celery offers. Temporal has first-class headers, which buys three things:
- Call signatures stay clean. Nothing is injected into your arguments.
- It survives the hops Celery doesn't have. Child workflows, signals, queries, updates
and
continue_as_newall keep the tenant. - Workflows stay deterministic. The schema is carried through workflow code, never used by it. Only activities touch the database.
Settings
All optional; the defaults are what most projects want.
DJANGO_TENANTS_TEMPORAL = {
"header_key": "__tenant_schema",
"default_schema": None,
"validate_tenant": True,
"tenant_cache_seconds": 0,
"close_old_connections": True,
"strict": True,
}
| Key | Default | Meaning |
|---|---|---|
header_key |
"__tenant_schema" |
Name of the Temporal header carrying the schema. |
default_schema |
None |
Schema to use when no header arrived. None leaves the connection untouched. |
validate_tenant |
True |
Check the schema exists and has a tenant row before entering it. |
tenant_cache_seconds |
0 |
Cache the tenant lookup per schema. 0 disables caching. |
close_old_connections |
True |
Recycle stale connections around each activity. See below. |
strict |
True |
A missing tenant raises a non-retryable error. False runs the activity unscoped. |
An unknown key raises ConfigError at startup with a spelling suggestion, rather than
silently doing nothing.
Why close_old_connections matters
Django recycles database connections at request boundaries. Celery gets the same treatment
from Django's signal hooks. Temporal has neither — worker threads are long-lived, so a
CONN_MAX_AGE connection the database has since dropped will resurface as InterfaceError
on a worker that has been idle. This package brackets every activity with
close_old_connections() so that cannot happen. Leave it on unless you have a specific
reason.
Workflow sandbox
Importing anything from this package inside a workflow file trips Temporal's sandbox:
temporalio.worker.workflow_sandbox._restrictions.RestrictedWorkflowAccessError:
Cannot access django.db.connection from inside a workflow.
Pass the package through:
from django_tenants_temporal import tenant_sandbox_runner
worker = TenantWorker(..., workflow_runner=tenant_sandbox_runner())
tenant_sandbox_runner("myapp.shared") passes extra modules through too. The package is
safe to pass through: the modules the sandbox loads (context, interceptor) import no
Django and hold no mutable state beyond a contextvar — there is a test asserting exactly
that.
The better habit is to keep workflow files free of Django imports and put ORM access in
activities, importing Django inside the function body. See tests/testapp/temporal_defs.py.
Schedules
Client.create_schedule is not on the interceptor chain, so scheduled workflows need the
header attached explicitly:
from temporalio.client import ScheduleActionStartWorkflow
from django_tenants_temporal import tenant_schedule_action
action = tenant_schedule_action(
ScheduleActionStartWorkflow(NightlyWorkflow.run, id="nightly", task_queue="default"),
schema="acme",
)
await client.create_schedule("nightly-acme", Schedule(action=action, spec=ScheduleSpec(...)))
This is the analogue of Celery beat's tenant-aware schedulers: one schedule per tenant, each stamped with its own schema.
Testing
Eager mode runs workflow bodies inline, calling activities directly — no server, no worker:
from django_tenants_temporal.testing import eager_mode, run_workflow_eagerly
def test_reminders(db):
assert run_workflow_eagerly(SendRemindersWorkflow, invoice.id, schema="acme") == 1
It lives in django_tenants_temporal.testing, not the package root, because it
monkey-patches temporalio.workflow.execute_activity. Importing it is a statement that
you are in a test.
Eager mode is deliberately faithful to production: activities are wrapped exactly as
TenantWorker wraps them, so the schema is entered, and they run in a worker thread rather
than on the event loop, so sync ORM calls behave the same way. Without both, an eager test
could pass while the real worker wrote to the wrong tenant.
For real end-to-end coverage use temporalio.testing.WorkflowEnvironment.start_local();
see tests/test_integration.py.
Limitations / help wanted
Two things are deliberately out of scope for v0.1. Both are good contributions and both have open issues.
Async activities are not tenant-aware
@activity.defn async def activities run fine and can read current_schema(), but nothing
enters the schema for them — the wrapper passes them through and logs a warning at
registration. Sync activities are the supported path.
Why it isn't a one-liner: schema_context issues a per-connection SET search_path, and
Django's connections are thread-local. An async activity runs on the event loop, so every
ORM call would have to be funnelled through sync_to_async(thread_sensitive=False) onto a
thread whose connection was actually switched. That deserves a deliberate design rather
than a wrapper that appears to work.
Workaround today:
@activity.defn
async def my_activity() -> int:
schema = current_schema()
def work():
with schema_context(schema):
return Invoice.objects.count()
return await sync_to_async(work, thread_sensitive=False)()
Single database only
There is no databases setting yet — the analogue of tenant-schemas-celery's
tenant_databases. schema_context targets get_tenant_database_alias() and nothing else.
A fix would wrap the activation in an ExitStack over the configured aliases, using
connection.set_schema() / restore for the non-default ones. Adding it is purely additive,
so it will not break the current API.
Caveats
- Workflows must not touch the ORM. They carry the schema; they don't use it. Database work belongs in activities. This is a Temporal determinism rule, not our restriction.
- Deleted tenants. With
validate_tenanton, an activity for a schema that no longer exists fails with a non-retryableApplicationErrorrather than retrying against a missing schema. - Shared worker vs worker-per-tenant. A single worker serves every tenant; the schema is per-call, not per-worker. Run a worker per tenant only if you need resource isolation.
CONN_MAX_AGE. Works as expected, but see the note onclose_old_connectionsabove.
Contributing
See CONTRIBUTING.md. docker compose up -d postgres, then
pytest -m "not integration" for the fast suite.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_tenants_temporal-0.1.1.tar.gz.
File metadata
- Download URL: django_tenants_temporal-0.1.1.tar.gz
- Upload date:
- Size: 17.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e56c729d6f48dd0c1a372e4db307ab99e7fae3f4347ab7908412eb3f22019a3a
|
|
| MD5 |
d8df3b17b8154c2057cb6b83cfe9e2da
|
|
| BLAKE2b-256 |
bc9a1ee2c35d6118f6e9a61484aaa7dd66c11892be5c44366fb1e1d39fd53a47
|
File details
Details for the file django_tenants_temporal-0.1.1-py3-none-any.whl.
File metadata
- Download URL: django_tenants_temporal-0.1.1-py3-none-any.whl
- Upload date:
- Size: 23.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3cb1b222936b791636c324c26186761cc42e160e7150a7770bcfb612568dd7d3
|
|
| MD5 |
7dd8f3c51fec5c1d30be52304e5298fe
|
|
| BLAKE2b-256 |
bae66772a0e2423f1ba24d25021422edde61e2fbe1f175718b50da374366196e
|