Repository navigation
Spike: prove that a Django-managed archive database works #362
Description
Overview
This spike (milestone M0) tests the risky parts of the archive design before other work starts. The spike code is temporary. The result is a set of findings, and changes to the design spec if a finding disagrees with it.
Background & Motivation
The disk export writes an archive: a SQLite file that Django manages as a second database. Morango has never used a second database at runtime. Several parts of the design depend on Django and django-mptt behavior that nobody tested in this codebase.
If one of these assumptions is wrong, later tasks build on a bad base. A short spike finds these problems early and at low cost.
Design: spec. Plan: implementation plan, Task 1.
Description & Expected Outcomes
The spike answers these questions:
- Can Django 3.2 add a database alias at runtime, with full default settings?
- Can
MigrationExecutorcreate all morango tables in that alias, without thecontenttypesapp? - Does django-mptt send queries to the default database when a certificate is saved with
save(using=alias)? - Can
bulk_createwith explicit tree fields write a certificate chain that parent walks can read? - Does
PRAGMA auto_vacuum=INCREMENTAL, set before migration, letPRAGMA incremental_vacuumshrink the file? - Can the alias be removed again, so that no trace stays in
connections.databases?
The spike runs on a SQLite host and on a PostgreSQL host.
Deliverables & Contracts
The spike delivers:
- Written findings for each question.
- Updates to spec §3.3 or §3.5 when a finding disagrees with the spec.
- No code that is committed to the repository.
Acceptance Criteria
- Each question has a recorded answer, with the host database (SQLite or PostgreSQL) for each result.
- If a finding disagrees with the spec, the spec is updated before Explicit database handle for sync database access #365 starts.
- The spike code is not committed to the repository.
Technical Pointers & Architecture
- Target Components: Django
connectionshandling,django.db.migrations.executor.MigrationExecutor,morango/models/certificates.py(Certificate, an MPTT model). - Related Patterns:
morango/deferrable_foreign_keys.pyalready works withschema_editor.connectionin a migration. - Data Model & Schema Considerations: The morango migrations declare no dependencies on other apps (
0001_squashed_0024…). - Resilience & Failure Modes: The spike records whether a restrictive router or a missing default setting causes a clear error.
Notes & Tradeoffs
- Dependencies: Blocked by: none. Blocks: Explicit database handle for sync database access #365, Create and discard an archive database file #378.
- Testing Requirements: Run on the default tox environment and on
tox -e postgres. - Tradeoffs & Alternatives Considered: The spec rejects a custom
sqlite3schema. If this spike fails, that decision needs review.
Metadata
- Complexity: Medium
- Target Branch: release-v0.9.x
AI Usage
Drafted with Claude (Claude Code) from the approved design spec and implementation plan. The author reviewed the requirements, and the code references were checked against the release-v0.9.x codebase.
Activity
bjester commentedon Oct 7, 2026
Spike findings: Django-managed archive database (#362)
Summary
The design is sound. A runtime alias,
MigrationExecutor,bulk_createwith explicit tree fields, and incremental auto-vacuum all work on SQLite and PostgreSQL hosts. The customsqlite3schema does not need to be reconsidered.The spike also found four problems that the spec does not cover. Each one would send archive writes to the host database:
DatabaseIDModel.save()runsDatabaseIDModel.objects.update(current=False)through the routed default manager. When the archive'sDatabaseIDModelis saved withusing=alias, it clearscurrenton the host's database ID.Model.save()withoutusingand foreign-key traversal (ts.sync_session,cert.parent) calldb_for_read/db_for_writefirst. They useinstance._state.dbonly when every router returnsNone. The test app'sTestingRouteralways returns"default", so in the test suite this sends archive reads and writes to the host database. Kolibri's routers returnNonefor morango models, so production falls back to the instance's database.ScopeDefinitionin the archive.Certificate.scope_definitionis a foreign key. Abulk_createof the chain fails withIntegrityErrorunless theScopeDefinitionrow exists in the archive. Spec §3.4 does not listScopeDefinition.InstanceIDModelhas nocalculate_uuid(). Instance ids come fromsha2_uuid(database_id, system_id, node_id)(morango/models/core.py:172).The spike code is kept in this planning worktree at
tests/testapp/tests/spike/test_archive_spike.py, and it is not committed. Run it with-sto see theFINDINGlines.Answers to the spike questions
All results are the same on both hosts unless a row says otherwise. The hosts are SQLite (the default settings) and PostgreSQL 9.6 (
testapp.postgres_settings). The archive is always SQLite. Versions: Django 3.2.25, django-mptt 0.18.0, Python 3.10.ENGINEandNAMEgets the full defaults (ATOMIC_REQUESTS,AUTOCOMMIT,CONN_MAX_AGE,OPTIONS,TEST,TIME_ZONE, …).load_backend(connections[alias])returns the SQLite wrapper on a PostgreSQL host. Assumption 1 holds.MigrationExecutorcreate all morango tables, withoutcontenttypes?("morango", "0005_self_ref_order"). Every concrete morango table is created, with no other app tables. Nopre_migrate/post_migratesignal fires, and no query reachesdefault.django_migrationsgets 29 rows: the squashed migration, all 24 migrations it replaces, and0002–0005.allow_migratereturnsFalsefor the alias, Django creates no tables but still records all 29 migrations as applied, and it raises no error. The table check in spec §3.5 step 7 is required.save(using=alias)?Certificate.save(certificates.py:274) wraps every save in_lock_mptt. That lock uses the defaultconnectionbackend, an un-aliasedtransaction.atomic(),lock_partitions(Postgres advisory locks on a PostgreSQL host), andparent.get_root(). mptt then runsSELECT MAX(tree_id)ondefault. If the tree fields are set first,save(using=alias)fails withDatabaseError("Save with update_fields did not affect any rows."). mptt reads go todefaulttoo:get_ancestors()on an archive instance queriesdefaultand returns[].bulk_createwith explicit tree fields write a chain that parent walks can read?ScopeDefinitionrow is in the archive first (finding 3). The spec §3.5 formula gives(lft, rght, level) = (1,6,0), (2,5,1), (3,4,2)for 3 nodes. Aparent_idwalk throughdb_manager(alias)reaches the root, and each signature verifies from the archive rows.cert.parentworks only when routers returnNone(finding 2).auto_vacuum=INCREMENTALbefore migration letincremental_vacuumshrink the file?auto_vacuumis 2 andjournal_modeisdelete. Test: 5,000Bufferrows of 1 KB each. The file is 7.69 MB, stays 7.69 MB after the delete (about 1,827 free pages), and is 0.20 MB afterincremental_vacuum. If the pragma is set after the tables exist,auto_vacuumstays 0.connections.databases?close(),del connections[alias]anddel connections.databases[alias]remove the alias fromconnections.databasesandconnections.all(). No-journalfile remains, andos.replaceworks (on Linux). ButConnectionHandleris thread-local: a connection that another thread opened stays open after the alias is removed.connections["default"].execute_wrappercatches ORM queries and savepoints ondefault, and it ignores alias queries. A baretransaction.atomic()is caught inside aTestCase(SAVEPOINT) on both hosts. Outside a test transaction, it is caught only on SQLite (BEGIN). On PostgreSQL, an empty outermostatomic()sends no SQL.{"super": {}, "sub": {}}, as Task 6 expects.Other findings
save()andobjects.create()on identity models write to the host. Finding 1 also applies toDatabaseIDManager.create. If the id is set first,save(using=alias)no longer runs theUPDATE, but it still opens a savepoint ondefault.bulk_createthroughdb_manager(alias)with preset ids writes both identity rows with zero default queries, and the host is unchanged.validate_and_create_buffer_datareadstransfer_session.sync_session.profile(sync/utils.py:97) and callstransfer_session.save(update_fields=...)(:147)._update_legacy_self_ref_orderreadstransfer_session.sync_session.profile(operations.py:748). Plan Task 6 already handles thesave. The foreign-key reads are not covered.KolibriModelRouter(kolibri/core/utils/model_router.py). That class returnsNonefromdb_for_read,db_for_writeandallow_migratefor morango models on an archive alias. Spec §3.9 item 1 is therefore already satisfied today. It becomes a constraint to keep, not a change to make.django_migrationslists replaced names. A fresh archive records the squashed migration and the 24 migrations it replaces. Phase 2's compatibility check must accept this.0006.0006_instanceidmodel_system_idalready exists among the replaced migrations. A new0006_archivemanifestentryis legal, but0006_…is not a unique prefix any more.Changes needed
Spec (
.specs/2026-09-29-disk-archive-export-spec.md, gist)Front matter: update
Last Updated, and setIssue/Ticket Referenceto Export sync data to an archive file with a disk connection #388.§2 Assumptions: mark Assumption 1 as verified by M0. Add a new assumption: hosts' routers return
Nonefromdb_for_readanddb_for_writefor morango models. Kolibri's routers do. The test app'sTestingRouterdoes not (see §5).§3.3 rule for converted functions: add two rules:
save()anddelete()passesusing=db.alias;<fk>_idwithdb.manager(...), orselect_relatedon a pinned queryset.Name the known spots:
validate_and_create_buffer_data(sync_session.profile,save) and_update_legacy_self_ref_order(sync_session.profile).§3.4 "Archive contents" table: add a
ScopeDefinitionrow: the scope definition of each certificate in the chain, copied from the host.§3.5
Archive:MigrationExecutoremits no signals and sends no queries todefault(verified).db.manager(...).bulk_createand preset ids. Never usesave()orobjects.create(), becauseDatabaseIDModel.saveclearscurrenton the routed default. The instanceid = sha2_uuid(database.id, system_id, node_id)(heresha2_uuid(database.id, "", "")), notcalculate_uuid().Certificate.save(_lock_mptt: default backend, bare atomic, advisory locks) and mptt (SELECT MAX(tree_id), reads) use the default database. A save with preset tree fields fails.write_certificateswrites the chain'sScopeDefinitionrows first, then the certificates.§3.6
create_sync_session: step (4) also writes the scope definition (throughwrite_certificates). Step (5) creates the archiveSyncSessionwithsave(using=archive.db.alias)or the pinned manager. Note that the minted certificate's localsave()(step 3) stays on the host, which is correct.§3.9 item 1: rewrite it as a constraint. Routers must return
None(orTrue) fromallow_migratefor archive aliases, andNonefromdb_for_read/db_for_writefor morango models. Kolibri'sKolibriModelRouteralready does both, so Kolibri needs no change, only a check.§4 Routers bullet: replace "explicit
usingoverrides …allow_migrateis the only router hook involved". Explicitusingand pinned managers override routers. Instancesave()and foreign-key traversal do not. They followdb_for_*first.§4 Archive alias registration and threads: add the thread-local connection finding. An open connection on another thread survives unregistration. On Windows, that open handle would also block
os.replace.§4 Constraints inherited by later phases (Phase 2): the
django_migrationscompatibility check must accept the squashed migration together with its replaced names.§5 M0 spike: replace the planned list with a short "Findings" summary that links to Spike: prove that a Django-managed archive database works #362.
§5 Default-DB guard and
TestingRouter: archive-path tests run with no routers, or with a router that returnsNonefor morango models, underoverride_settings(DATABASE_ROUTERS=[]). OtherwiseTestingRouterhides finding 2. Add a test that the guard catches a bareatomic()inside aTestCase.§6 Never do: add "Call
save()orobjects.create()onDatabaseIDModelfor the Archive" and "Traverse foreign keys on Archive objects".§3.10 M0 row: mark it done.
Plan (
.plans/2026-09-30-disk-archive-export.md, gist)tests/testapp/tests/spike/") with "findings recorded in Spike: prove that a Django-managed archive database works #362. The spike code stays in the planning worktree."using=on every instance save or delete on the archive path, and never traverse foreign keys there;override_settings(DATABASE_ROUTERS=[]).transaction.atomic()inside the guard raises (savepoint).{"super": {}, "sub": {}}). Addtransfer_session.sync_session.profile→db.manager(SyncSession)lookup bysync_session_id. Run thedefault2tests withoverride_settings(DATABASE_ROUTERS=[]), becauseTestingRouterwould senddefault2instances todefault.sync_session.profilechange in_update_legacy_self_ref_order.MorangoArchiveError(confirmed). Step 3: open and unregister the alias on the calling thread.bulk_createwith preset ids, andid == sha2_uuid(db_id, "", ""). Add a test that the hostDatabaseIDModel.currentstaysTrue.write_certificatesalso writes the chain'sScopeDefinitionrows (skip ones that exist). Add a test that a chain whose scope definition is missing from the archive still writes.SyncSessionis written with the pinned manager. The test asserts the archive holds theScopeDefinition.ScopeDefinition.Nonefor morango models on archive aliases (Kolibri already does).Issues (online versions are the source of truth)
TODO: needs clarification. Replace the third criterion (spike directory deleted) with "spike code kept out of the repository".transaction.atomic()ondefaultinside a test transaction".transfer_session.sync_session.profileread atoperations.py:748. Add the criterion "tests with a pinned handle run with no routers".sync_session.profileread and thesaveatsync/utils.py:97and:147. Remove the dependency on the spike's FSIC shape. Add the no-routers testing note.sha2_uuid(database_id, "", ""). Requirebulk_create(or a pinned manager withoutsave), and give the hostcurrentflag as the reason. Add the criterion "the hostDatabaseIDModel.currentis stillTrue".Certificate.saveand mptt use the default database. AddScopeDefinitionto the deliverables, with the criterion "a chain whose scope definition is not in the archive is written without anIntegrityError".ScopeDefinition, and the archiveSyncSessionis written to the archive alias.ScopeDefinition.Nonefor morango models on archive aliases. Kolibri already does.db_for_read/db_for_writeas well asallow_migrate, and Kolibri's current routers already meet it. In Export sync data to an archive file with a disk connection #388, also mark M0 done.