django-sqlite-ops 0.1.0a2__tar.gz → 0.1.0a3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_sqlite_ops-0.1.0a3/CHANGELOG.md +64 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/PKG-INFO +77 -27
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/README.md +75 -26
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/boot/cli.py +57 -6
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/boot/decide.py +41 -1
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/boot/litestream.py +178 -27
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/checks.py +45 -1
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/doctor.py +36 -1
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/health.py +105 -18
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/DESIGN.md +46 -22
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/profiles/single-server.md +31 -9
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/reference/README.md +1 -1
- django_sqlite_ops-0.1.0a3/docs/research/lab-2026-11-12-litestream-0.5.18.md +127 -0
- django_sqlite_ops-0.1.0a3/docs/research/lab-2026-11-12-litestream-pitfalls-0.5.18.md +144 -0
- django_sqlite_ops-0.1.0a3/docs/research/lab-2026-11-12-wal-autocheckpoint.md +188 -0
- django_sqlite_ops-0.1.0a3/docs/research/lab-2026-11-13-health-pipeline-stall.md +102 -0
- django_sqlite_ops-0.1.0a3/docs/research/lab-2026-11-13-profile-image.md +85 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/README.md +54 -14
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/_checks.py +95 -11
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/_lab.py +27 -2
- django_sqlite_ops-0.1.0a3/lab/_walcpstat.py +133 -0
- django_sqlite_ops-0.1.0a3/lab/app/lab_tools/lsctl.py +73 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/lab_tools/replica.py +13 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/lab_tools/s3_objects.py +22 -0
- django_sqlite_ops-0.1.0a3/lab/app/lab_tools/walcp_load.py +212 -0
- django_sqlite_ops-0.1.0a3/lab/app/lab_tools/walcp_sup.py +77 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/metrics.py +33 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/views.py +17 -3
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/proj/settings_tail.py +3 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/build_context.py +1 -1
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/conftest.py +21 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/lab-compose.yaml +13 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/nats.Dockerfile +2 -1
- django_sqlite_ops-0.1.0a3/lab/test_pipeline.py +205 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/test_scenarios.py +63 -14
- django_sqlite_ops-0.1.0a3/lab/test_walcp.py +526 -0
- django_sqlite_ops-0.1.0a3/lab/walcp_report.py +147 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/pyproject.toml +2 -1
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/scripts/lab.sh +23 -6
- django_sqlite_ops-0.1.0a3/scripts/litestream_watch.py +263 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/_litestream.py +57 -0
- django_sqlite_ops-0.1.0a3/tests/conftest.py +24 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/README.md +5 -1
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/README.md +61 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_bad_yaml/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_bad_yaml/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_bad_yaml/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_config_missing/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_config_missing/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_config_missing/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_empty/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_empty/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_empty/stdout +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_json/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_json/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/databases_json/stdout +22 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/local_meta_after_replicate/meta/ltx/0/0000000000000019-0000000000000019.ltx +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_all_levels/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_all_levels/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_all_levels/stdout +107 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_all_levels_text/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_all_levels_text/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_all_levels_text/stdout +16 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_bad_yaml/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_bad_yaml/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_bad_yaml/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_config_missing/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_config_missing/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_config_missing/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_db_not_in_config/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_db_not_in_config/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_db_not_in_config/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_l0_default/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_l0_default/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_l0_default/stdout +9 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_no_l0_all_levels/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_no_l0_all_levels/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_no_l0_all_levels/stdout +100 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_no_l0_default/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_no_l0_default/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_no_l0_default/stdout +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_permission_denied/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_permission_denied/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_permission_denied/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_replica_empty_dir/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_replica_empty_dir/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_replica_empty_dir/stdout +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_replica_missing/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_replica_missing/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_replica_missing/stdout +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/ltx_sample/0000000000000001-0000000000000001.ltx +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_no_backups/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_no_backups/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_no_backups/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_ok_json/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_ok_json/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_ok_json/stdout +8 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_output_exists/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_output_exists/stderr +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/restore_output_exists/stdout +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/version/rc +1 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/version/stderr +0 -0
- django_sqlite_ops-0.1.0a3/tests/fixtures/litestream-0.5.18/version/stdout +1 -0
- django_sqlite_ops-0.1.0a2/tests/fixtures/litestream-0.5.17/make_fixtures.sh → django_sqlite_ops-0.1.0a3/tests/fixtures/make_litestream_fixtures.sh +32 -12
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_boot_cli.py +247 -2
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_boot_decide.py +37 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_boot_litestream.py +343 -115
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_checks.py +271 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_doctor.py +102 -9
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_health.py +10 -7
- django_sqlite_ops-0.1.0a3/tests/test_health_pipeline.py +586 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_lab.py +152 -0
- django_sqlite_ops-0.1.0a3/tests/test_litestream_pitfalls.py +389 -0
- django_sqlite_ops-0.1.0a3/tests/test_litestream_watch.py +304 -0
- django_sqlite_ops-0.1.0a3/tests/test_support_matrix.py +43 -0
- django_sqlite_ops-0.1.0a2/CHANGELOG.md +0 -41
- django_sqlite_ops-0.1.0a2/tests/conftest.py +0 -13
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/.gitignore +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/LICENSE +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/apps.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/boot/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/boot/__main__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/compat/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/compat/_channels_lite_aio.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/compat/channels_lite.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/database.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/management/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/management/commands/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/management/commands/sqlite_doctor.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/restore_check.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/django_sqlite_ops/wal.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/DECISIONS.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/assets/charts.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/assets/conn-max-age.svg +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/assets/dbconfig.svg +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/profiles/fd-limits.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/profiles/single-server-multiproc.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/reference/Dockerfile +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/reference/compose.yaml +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/reference/entrypoint.sh +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/reference/guard_spike.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/reference/vfs_workaround_spike.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/bench-2026-10-08.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/bench-2026-10-26-default-vs-recommended.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/cache-size-2026-10-12.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/cross-review-gpt-6-astra.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/data/conn-max-age-2026-10-08.csv +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/data/dbconfig-2026-10-26.csv +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/fd-soak-2026-10-09.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/handoff-marco-layer-wording.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/handoff-marco-nats-flush-cancel.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/lab-2026-10-08.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/lab-2026-10-25-l4-two-way.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/lab-2026-10-25-long-outage.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/lab-2026-10-26-multiproc-nats.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/litestream-django-docker.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/litestream-django-scenarios.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/sqlite-django-package-review-v0.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/sqlite-django-package-review-v1.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/docs/research/turso-django.md +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/_benchstat.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/_cachestat.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/_soakstat.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/lab_hooks/sitecustomize.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/lab_tools/chanlab_run.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/lab_tools/inspect_data.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/lab_tools/loadgen.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/lab_tools/procs.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/manage.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/apps.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/chanlab.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/management/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/management/commands/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/management/commands/lab_seed.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/migrations/0001_initial.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/migrations/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/models.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/notes/timing.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/proj/__init__.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/proj/asgi.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/app/proj/urls_tail.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/cache_mech.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/cache_probe.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/cache_report.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/dbconfig_report.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/multiproc-override.yaml +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/profile-override.yaml +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/soak_report.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/test_bench.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/test_dbconfig.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/lab/test_soak.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/scripts/ci-local.sh +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/scripts/release_check.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/_channels_lite.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/_nodjango.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_bad_yaml/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_bad_yaml/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_bad_yaml/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_config_missing/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_config_missing/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_config_missing/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_empty/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_empty/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_empty/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_json/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_json/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/databases_json/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/local_meta_after_replicate/meta/ltx/0/0000000000000019-0000000000000019.ltx +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_all_levels/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_all_levels/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_all_levels/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_all_levels_text/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_all_levels_text/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_all_levels_text/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_bad_yaml/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_bad_yaml/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_bad_yaml/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_config_missing/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_config_missing/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_config_missing/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_db_not_in_config/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_db_not_in_config/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_db_not_in_config/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_l0_default/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_l0_default/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_l0_default/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_no_l0_all_levels/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_no_l0_all_levels/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_no_l0_all_levels/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_no_l0_default/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_no_l0_default/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_no_l0_default/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_permission_denied/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_permission_denied/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_permission_denied/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_replica_empty_dir/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_replica_empty_dir/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_replica_empty_dir/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_replica_missing/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_replica_missing/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_replica_missing/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/ltx_sample/0000000000000001-0000000000000001.ltx +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_no_backups/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_no_backups/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_no_backups/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_ok_json/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_ok_json/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_ok_json/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_output_exists/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_output_exists/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/restore_output_exists/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/version/rc +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/version/stderr +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/fixtures/litestream-0.5.17/version/stdout +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_compat_channels_lite.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_database.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_doc_links.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_environment.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_profiles.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_readme.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_release_check.py +0 -0
- {django_sqlite_ops-0.1.0a2 → django_sqlite_ops-0.1.0a3}/tests/test_restore_check.py +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
[Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 형식을 따르고, 버전은 [PEP 440](https://peps.python.org/pep-0440/) 으로 매긴다. 릴리스할 때 `[Unreleased]` 의 항목을 새 버전 절로 옮긴다(`scripts/release_check.py` 가 확인한다).
|
|
4
|
+
|
|
5
|
+
> ⚠️ **실험적 프로젝트다.** 1.0 전에는 API·동작·지원 범위가 예고 없이 바뀔 수 있다. 사전 릴리스(`a`·`b`·`rc`)는 버전을 명시해 설치한다(`pip install "django-sqlite-ops==0.1.0a3"`). `--pre` 는 모든 의존성의 사전 릴리스까지 허용하므로 쓰지 않는다. 운영 환경에서는 버전을 고정한다.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0a3] - 2026-11-15
|
|
10
|
+
|
|
11
|
+
헬스 판정을 좁히고 지원 범위를 넓힌 사전 릴리스다. Python 3.15 와 Litestream 0.5.18 을 받고, 시스템 체크·doctor·boot 의 안내를 더했으며, 헬스의 `caught_up` 이 멈춘 복제 파이프라인을 감추지 않게 했다. 배포 프로필의 Dockerfile 기준 이미지가 바뀌었다. 저장소 안의 CI·회귀 랩·측정(#104·#105·#106)은 배포물에 영향이 없으므로 여기 적지 않는다.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Python 3.15 를 지원한다.** Django 6.1(3.15 를 지원하는 6.1.3 이상)과 함께다. Django 5.2 는 Python 3.14 까지 지원하므로 3.15 × Django 5.2 는 지원 범위가 아니다. CI 가 3.15 에서 전체 테스트와 실제 Litestream(0.5.17·0.5.18)·channels-lite 통합 테스트를 돌고, classifier 에 `Programming Language :: Python :: 3.15` 를 넣었다 (#101)
|
|
16
|
+
- **시스템 체크 `sqlite_ops.W006`: `ATOMIC_REQUESTS` 와 `transaction_mode` `IMMEDIATE`(또는 `EXCLUSIVE`)를 함께 쓰면 알린다.** 이 조합에서는 뷰마다 `BEGIN IMMEDIATE` 가 쓰기 잠금을 뷰 끝까지 쥐어 읽기만 하는 뷰도 직렬로 돈다(0.3초 뷰 4개 동시 호출에 0.32·0.67·0.99·1.31초, 끄면 모두 0.32초 — 재현함). 항상 도는 체크이고 DB 를 열지 않는다. 읽기 전용·VFS·메모리 DB 별칭에는 내지 않는다. 힌트는 `ATOMIC_REQUESTS` 를 끄고 쓰는 뷰에만 `transaction.atomic()` 을 쓰거나 읽기 뷰에 `transaction.non_atomic_requests` 를 붙이라고 안내한다. 설정은 바꾸지 않는다. 의도한 구성이면 `SILENCED_SYSTEM_CHECKS` 로 끈다. `check --fail-level WARNING` 을 쓰는 CI 는 이 조합에서 실패로 바뀐다 (#102)
|
|
17
|
+
- **`sqlite_doctor` 가 SQLite WAL-reset 버그의 영향 범위인 판을 알린다.** SQLite 공식 문서(https://sqlite.org/wal.html#the_wal_reset_bug, 2026-11-12 확인)의 범위(3.7.0~3.51.2, 3.51.3 에서 수정, 3.44.6·3.50.7 로 백포트)에 들면 `sqlite_version` 줄에 메시지를 적는다. 판 번호만 보므로 배포판이 판 번호를 바꾸지 않고 패치했을 수 있다. **수준은 `ok` 이고 종료 코드는 바뀌지 않는다**(공식 `python:*-slim` 이미지의 3.46.1 은 범위 안이다. 배포 프로필 이미지는 #117 부터 SQLite 3.53.1 이라 메시지가 나오지 않는다). 철회판 3.52.0 은 수정 check-in 을 조상으로 두므로 범위로 보지 않는다(sqlite.org 타임라인, 2026-11-13 확인). `--json` 의 키는 그대로다. 판정 함수 `doctor.wal_reset_affected()` 를 둔다 (#103·#117)
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **헬스의 `caught_up` 이 좁아졌다 — 복제 파이프라인이 멈춘 것을 알면 `backlog` 다.** `caught_up` 은 "TXID·WAL 위치가 맞고 파이프라인이 멈췄다는 근거가 없다"는 뜻이 됐다. 새로 나오는 판정은 셋이다. (1) `backlog`/`compaction_stalled`: 다른 판정이 `caught_up` 인데 L1 이 덮지 않은 가장 오래된 원격 L0 가 가장 새 원격 객체보다 `COMPACTION_GRACE` 이상 오래됨 — 로컬 L0 손상 등으로 L1 컴팩션이 멈춘 것이다(예전에는 TXID 가 같아 `caught_up` 이었다). 새 설정 `SQLITE_OPS["HEALTH"]["COMPACTION_GRACE"]`(기본 300초, `None` 이면 끔)이고, Litestream 의 `levels` 에서 L1 `interval` 을 바꿨다면 그 10배 이상으로 둔다. (2) `backlog`/`db_not_replicated`: Litestream 없이 DB 가 바뀐 채 `BACKLOG_GRACE` 가 지남 — 요청마다 연결을 닫는 앱에서 WAL 근거와 파일 시각 근거가 번갈아 나와도 같은 최신 L0 면 이어 센다(예전에는 그때마다 다시 세어 `unknown`·`caught_up` 을 오갔다). (3) `unknown`/`no_compaction_evidence`: 복제본 객체의 시각을 읽지 못해 컴팩션 진행을 판정할 수 없음(검증한 판에서는 나오지 않는다). 응답 필드와 `version` 은 그대로이고 원격 호출은 늘지 않는다(이미 부르는 `ltx -level all -json` 을 쓴다). 한계: 쓰기가 없는 동안의 정지(유휴 중 Litestream 없음 포함)는 다음 쓰기 전까지 `caught_up` 으로 남는다. **운영 영향: `?strict=1` 이나 "`caught_up` 이 아니면 경보" 규칙이 이 상태들에서 새로 울릴 수 있다** (#115)
|
|
22
|
+
- **배포 프로필의 Dockerfile 이 WAL-reset 버그가 고쳐진 SQLite 로 Python 을 돌린다.** 기준 이미지가 `python:3.13-slim`(SQLite 3.46.1, 영향 범위)에서 `debian:trixie-slim` + uv 가 받는 Python 3.14(python-build-standalone, SQLite 3.53.1 정적 포함)로 바뀌었다. 빌드 중 SQLite 가 3.51.3 미만이면 빌드가 멈춘다. Debian(trixie `deb13u2`·bookworm `deb12u2`)에는 수정이 없고 Ubuntu 수정은 `-proposed` 에만 있다(2026-11-13 확인). 이 Dockerfile 을 따라 쓰던 운영자에게 바뀌는 것: 패키지는 `/opt/venv`(`PATH` 첫 자리)에 들어가고 **`pip` 가 없다** — 더할 패키지는 `uv pip install` 로 넣는다. Python 판은 `ARG PYTHON_VERSION`(기본 3.14)이다. Python·SQLite·OpenSSL 의 보안 업데이트는 `apt` 가 아니라 **uv 판(`ghcr.io/astral-sh/uv:<판>`)을 올리고 다시 빌드**할 때 들어온다. 이미지가 커진다(로컬 측정 arm64 376→475MB, amd64 355→492MB). Litestream 0.5.17·0.5.18 은 자체 SQLite 3.53.0 을 품어 원래 영향 범위가 아니었다. 패키지 코드는 바뀌지 않았다 (#117)
|
|
23
|
+
- **Litestream 0.5.18 을 검증한 판으로 받는다.** boot·`sqlite_doctor` 의 Litestream 설정 대조·헬스·`--restore-test` 가 0.5.17 과 0.5.18 을 받고 그 밖의 판은 계속 거부한다. 0.5.18 실측 fixture(`file://` 복제본의 `ltx`·`databases`·`restore`)의 출력 형식·rc·오류 문구는 0.5.17 과 같았다(시각·크기·압축 묶음만 다름). 배포 프로필 `single-server` 의 Dockerfile 이 0.5.18 을 설치한다 (#99)
|
|
24
|
+
- **S3 가 연결을 거부할 때 0.5.18 은 더 빨리 실패한다.** 0.5.17 의 `ltx` 는 30초 타임아웃까지 매달려 사유가 `timed out` 이었고, 0.5.18 은 약 8초 뒤 `exited with 1: … exceeded maximum number of attempts` 로 끝난다. boot 의 거부(exit 2, `remote_error`)와 헬스의 `unknown` 은 두 판에서 같고, 헬스의 `stale`↔`remote_error` 주기만 짧아진다. 0.5.18 은 이때 Litestream ERROR 로그도 남긴다 (#99)
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Litestream 버전 게이트에서 거부할 때 boot 의 힌트가 그 원인을 말한다.** 지금까지는 검증하지 않은 판·바이너리 없음·버전 출력을 읽을 수 없음도 복제본 접근(endpoint·자격 증명·네트워크)을 고치라고 안내했다. 이제 설치된 판과 검증한 판 목록, 바이너리 경로와 설치 안내, 받은 출력을 보여 주고 검증한 판을 설치하거나 `--litestream` 으로 가리키라고 한다. 사유 코드(`remote_error`)·종료 코드(2)·판정은 그대로다. `check_version()` 은 사유 문자열 대신 `VersionProblem`(종류·사유·세부)을 돌려주고, `RemoteError` 에 실패 종류(`failure`)와 세부(`detail`)가 붙는다 (#100)
|
|
29
|
+
- **원격 조회가 실패한 채 `stale_meta`·`empty_db`·`orphan_sidecars` 로 거부될 때 힌트가 조회부터 고치라고 한다.** 지금까지는 조회가 실패한 동안에도 `--on-unknown restore` 를 권해 그대로 다시 돌리면 같은 거부로 돌아왔다. 이제 조회 실패의 원인과 대처가 먼저, `then:` 뒤에 조회가 된 뒤 할 일이 나온다. `empty_db` 힌트 끝의 "if the remote lookup failed, fix replica access first" 는 이 앞부분으로 옮겨 지웠다 (#100)
|
|
30
|
+
|
|
31
|
+
## [0.1.0a2] - 2026-10-27
|
|
32
|
+
|
|
33
|
+
의존성 하한과 문서를 고친 사전 릴리스다. 패키지 코드(`django_sqlite_ops/`)는 0.1.0a1 과 같다. 저장소 안의 회귀 랩·CI 정비(#88·#90·#94)는 sdist 에는 실리지만 설치되는 코드(wheel)에는 영향이 없으므로 여기 적지 않는다.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`[nats]` extra 가 `channels-nats>=0.10.1` 을 요구한다.** 0.10.0 이하에서는 구독 확인이 취소되거나 시간 초과되면 그 워커의 채널 수신이 예외 없이 멈춘다. 0.10.1 은 같은 API 의 패치 릴리스다 (#91·#88)
|
|
38
|
+
- **README(PyPI 설명)를 고쳤다.** '왜 쓰는가' 절에 Django 기본 설정 대 권장 설정, `CONN_MAX_AGE` 의 실측 차트와 boot 의 실패 경계 L1–L8 표를 넣었다. 나빠진 지표와 재현되지 않은 이득도 함께 적는다 (#83). 설치 절에 channels-nats 하한 안내를 넣었다 (#91). 문제 해결 '채널 레이어'의 channels-nats 결함 안내를 0.10.1 기준으로 고쳤다 (#88)
|
|
39
|
+
- 배포 프로필 `single-server` 의 `requirements.txt` 예가 이 패키지를 PyPI 에서 받는다 (#84)
|
|
40
|
+
|
|
41
|
+
## [0.1.0a1] - 2026-10-26
|
|
42
|
+
|
|
43
|
+
첫 실험적 사전 릴리스다([D-28](docs/DECISIONS.md)). v0.1 범위([DESIGN §3-1](docs/DESIGN.md#3-범위))를 담는다. 지원 범위: Python 3.13·3.14, Django 5.2·6.1, SQLite 3.37.2 이상, Litestream 0.5.17. boot CLI 는 POSIX(Linux·macOS)만 지원한다.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **권장 설정** `sqlite_database()`: WAL·`busy_timeout`·`synchronous` 등 실측 근거가 있는 PRAGMA 표 하나를 정본으로, 표준 `DATABASES` 항목을 만든다. 프로필, `pragmas`·`options` 덮어쓰기, 잘못된 입력 거부 (#1)
|
|
48
|
+
- **boot CLI** `python -m django_sqlite_ops.boot`: Django 를 import 하지 않고 기동 전에 DB 상태를 판정해 Litestream 복원·신규 생성·기존 DB 도입을 결정한다. 판정할 수 없으면 사유 코드와 함께 거부(exit 2)하고, 파일 잠금으로 동시 실행을 막는다 (#2·#3·#4)
|
|
49
|
+
- **시스템 체크** `sqlite_ops.*`: DB 를 열지 않고 설정만으로 `SQLITE_OPS` 오류, `transaction_mode`·WAL 미설정, VFS 별칭의 `CONN_MAX_AGE`, ASGI 에서의 `CONN_MAX_AGE=None` 을 알린다 (#5)
|
|
50
|
+
- **`sqlite_doctor` 관리 명령**: 실제 연결로 PRAGMA·WAL·Litestream 복제 상태를 진단한다. 종료 코드 0/1/2 (#6)
|
|
51
|
+
- **`sqlite_doctor --restore-test`**: 복제본을 별도 디렉터리에 복원해 무결성 검사·TXID 비교로 복원 가능성을 검증한다. 운영 DB 와 Litestream 메타 경로는 건드리지 않는다 (#52·#54)
|
|
52
|
+
- **복제 헬스 뷰**: 로컬과 복제본의 TXID 를 직접 비교해 `caught_up`·`backlog`·`unknown`(사유 코드 `stale`·`remote_error` 등)을 JSON 으로 낸다 (#7·#65·#72)
|
|
53
|
+
- **배포 프로필 문서**: 단일 서버, 단일 서버 다중 프로세스, fd 한도 (#9·#32)
|
|
54
|
+
- **compat 패치**: channels-lite aio 레이어 결함의 버전 게이트 몽키패치 (#8)
|
|
55
|
+
- **회귀 랩**: Docker(SeaweedFS·toxiproxy)로 실패 경계 L1–L8 을 재현하는 수동 실행 랩(`scripts/lab.sh`, GitHub Actions `lab.yml`). 기본 CI 밖이며 wheel 에는 들어가지 않는다(sdist 에는 소스로 들어간다) (#10)
|
|
56
|
+
|
|
57
|
+
### Known limitations
|
|
58
|
+
|
|
59
|
+
- 미검증 항목은 [DESIGN §12](docs/DESIGN.md#12-미검증확인-필요) 에 있다. 실제 클라우드 S3·R2·Tigris, Windows 의 boot CLI, ASGI 에서의 VFS 별칭 동작은 검증하지 않았다.
|
|
60
|
+
|
|
61
|
+
[Unreleased]: https://github.com/itda-work/django-sqlite-ops/compare/v0.1.0a3...HEAD
|
|
62
|
+
[0.1.0a3]: https://github.com/itda-work/django-sqlite-ops/compare/v0.1.0a2...v0.1.0a3
|
|
63
|
+
[0.1.0a2]: https://github.com/itda-work/django-sqlite-ops/compare/v0.1.0a1...v0.1.0a2
|
|
64
|
+
[0.1.0a1]: https://github.com/itda-work/django-sqlite-ops/releases/tag/v0.1.0a1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: django-sqlite-ops
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a3
|
|
4
4
|
Summary: [Experimental] Operations toolkit for running SQLite as a production database in Django
|
|
5
5
|
Project-URL: Homepage, https://github.com/itda-work/django-sqlite-ops
|
|
6
6
|
Project-URL: Repository, https://github.com/itda-work/django-sqlite-ops
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
23
24
|
Classifier: Topic :: Database
|
|
24
25
|
Requires-Python: >=3.13
|
|
25
26
|
Requires-Dist: django>=5.2
|
|
@@ -74,15 +75,16 @@ Litestream 만 쓰면 옛 볼륨으로 재부팅했을 때 최신 복제본이
|
|
|
74
75
|
| L1 볼륨 없는 새 컨테이너 | 복제본에서 복원, 행 수 일치 |
|
|
75
76
|
| L2 옛 볼륨으로 재부팅(복제본이 더 앞섬) | exit 2 `remote_ahead`, 복제본 손상 0 |
|
|
76
77
|
| L3 로컬 메타만 사라짐 | exit 2 `no_local_meta`, DB 무변경 |
|
|
77
|
-
| L4 부팅 중 S3 끊김(거부·무응답) |
|
|
78
|
+
| L4 부팅 중 S3 끊김(거부·무응답) | 원격 조회 실패 뒤 exit 2 `remote_error`(무응답은 타임아웃. 연결 거부는 0.5.17 이 타임아웃, 0.5.18 이 약 8초에 rc 1, #99) |
|
|
78
79
|
| L5 복제되지 않은 로컬 커밋 | 그대로 진행, 커밋 보존 |
|
|
79
80
|
| L6 복원 도중 kill | 반쯤 옮겨진 상태 없음, 재실행으로 완료 |
|
|
80
81
|
| L7 복제본 객체 손상 | exit 4, 로컬 파일 무변경 |
|
|
81
82
|
| L8 S3 끊김 중 헬스 | 업로드만 끊기면 `backlog`, 조회까지 끊기면 `unknown`. 복구 뒤 `caught_up` |
|
|
83
|
+
| L9 복제 파이프라인 정지 중 헬스(#115) | 로컬 L0 손상으로 컴팩션이 멈추면 `backlog`(`compaction_stalled`), Litestream 만 죽으면 `backlog`(`db_not_replicated`) |
|
|
82
84
|
|
|
83
85
|
## 현재 상태
|
|
84
86
|
|
|
85
|
-
PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md), 2026-10-26 결정). 첫 버전은 `0.1.0a1`(2026-10-26), 지금은 `0.1.
|
|
87
|
+
PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md), 2026-10-26 결정). 첫 버전은 `0.1.0a1`(2026-10-26), 지금은 `0.1.0a3` 이다. 사전 릴리스는 버전을 명시해 설치한다(아래 [설치](#설치)). 바뀐 점은 [CHANGELOG](https://github.com/itda-work/django-sqlite-ops/blob/main/CHANGELOG.md) 에 적는다. 외부 홍보는 하지 않는다. 패키지 이름 `django-sqlite-ops` 는 확정이다(D-28 이 D-6 을 갱신).
|
|
86
88
|
|
|
87
89
|
| 기능 | 상태 | 이슈·근거 |
|
|
88
90
|
|---|---|---|
|
|
@@ -93,27 +95,27 @@ PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](https://github.co
|
|
|
93
95
|
| [복제 헬스](#복제-헬스) | 구현됨 | [#7](https://github.com/itda-work/django-sqlite-ops/issues/7) |
|
|
94
96
|
| [channels-lite 패치](#channels-lite-패치) | 구현됨 | [#8](https://github.com/itda-work/django-sqlite-ops/issues/8) |
|
|
95
97
|
| [배포 프로필 문서](#배포-프로필) | 구현됨 | [#9](https://github.com/itda-work/django-sqlite-ops/issues/9) |
|
|
96
|
-
| 회귀 랩(Docker 로 실패 경계 L1–
|
|
98
|
+
| 회귀 랩(Docker 로 실패 경계 L1–L9 재현, 프로필 Dockerfile·compose 종단 검증, PRAGMA 벤치). 수동 실행(`scripts/lab.sh`, GitHub Actions `lab.yml`), 기본 CI 밖. arm64·amd64 모두 통과 | 구현됨 | [#10](https://github.com/itda-work/django-sqlite-ops/issues/10) · [lab/README.md](https://github.com/itda-work/django-sqlite-ops/blob/main/lab/README.md) · [결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md) |
|
|
97
99
|
| [복원 검증](#복원-검증---restore-test) `sqlite_doctor --restore-test` | 구현됨 | [#52](https://github.com/itda-work/django-sqlite-ops/issues/52)(설계) · [#54](https://github.com/itda-work/django-sqlite-ops/issues/54) · [DESIGN §6-2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#6-2-sqlite_doctor-명시-실행-실제-연결) |
|
|
98
100
|
| VFS 읽기 복제본용 DB 라우터 | 예정(2단계) | [DESIGN §3-2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#3-2-2단계-이후) |
|
|
99
101
|
|
|
100
102
|
## 설치
|
|
101
103
|
|
|
102
|
-
요구 버전: Python 3.13
|
|
104
|
+
요구 버전: Python 3.13+(CI 는 3.13·3.14·3.15), Django 5.2+, SQLite 3.37+(단 3.51.2 이하는 [WAL-reset 버그](#sqlite-wal-reset-버그)의 영향 범위다). Python 3.15 는 Django 6.1.3 이상과 함께 쓴다(Django 5.2 는 3.14 까지 지원). boot CLI 는 POSIX(Linux·macOS)와 PATH 의 `litestream` 0.5.17 또는 0.5.18 이 필요하다([boot CLI](#boot-cli)).
|
|
103
105
|
|
|
104
106
|
PyPI 에서 설치한다. 사전 릴리스는 버전을 명시해 설치한다. 실험적 프로젝트이므로 그 버전으로 고정해 둔다.
|
|
105
107
|
|
|
106
108
|
```bash
|
|
107
|
-
pip install "django-sqlite-ops==0.1.
|
|
109
|
+
pip install "django-sqlite-ops==0.1.0a3"
|
|
108
110
|
# 또는
|
|
109
|
-
uv add "django-sqlite-ops==0.1.
|
|
111
|
+
uv add "django-sqlite-ops==0.1.0a3"
|
|
110
112
|
```
|
|
111
113
|
|
|
112
114
|
필수 의존성은 Django 하나다. 채널 레이어는 extra 로 고른다: `[nats]`(channels-nats), `[channels-lite]`(channels-lite 만. aio 레이어에 필요한 의존성은 [channels-lite 패치 '함정'](#channels-lite-패치)). **channels-nats 는 0.10.1 이상을 쓴다** — 0.10.0 이하는 구독 확인이 취소되면 그 워커가 채널 메시지를 더 받지 못한다([문제 해결 '채널 레이어'](#채널-레이어)). 0.1.0a2 부터 `[nats]` 가 `channels-nats>=0.10.1` 을 요구한다(#91). 0.1.0a1 을 쓴다면 `[nats]` 에 하한이 없으므로 `channels-nats>=0.10.1` 을 함께 적는다. lock 파일에 0.10.0 이하가 잠겨 있으면 `uv lock --upgrade-package channels-nats` 처럼 그 패키지를 올린다.
|
|
113
115
|
|
|
114
116
|
```bash
|
|
115
|
-
pip install "django-sqlite-ops[nats]==0.1.
|
|
116
|
-
uv add "django-sqlite-ops[channels-lite]==0.1.
|
|
117
|
+
pip install "django-sqlite-ops[nats]==0.1.0a3"
|
|
118
|
+
uv add "django-sqlite-ops[channels-lite]==0.1.0a3"
|
|
117
119
|
```
|
|
118
120
|
|
|
119
121
|
`pip install --pre` 나 `uv add --prerelease=allow` 는 쓰지 않는다. 이 패키지뿐 아니라 Django 를 비롯한 **모든 의존성**의 사전 릴리스까지 허용한다(`uv add --prerelease=allow` 는 `uv.lock` 에 `prerelease-mode = "allow"` 로 남아 이후의 해석에도 적용된다). 버전을 지정자에 명시하면 그 사전 릴리스만 받아들여진다. 정식 릴리스가 나온 뒤에는 명시하지 않으면 사전 릴리스를 고르지 않는다.
|
|
@@ -233,7 +235,7 @@ curl -s http://127.0.0.1:8000/internal/sqlite-health # 본문 status 가 caugh
|
|
|
233
235
|
| `synchronous` | `NORMAL` | WAL 에서 쓰기 비용을 줄인다. 체크포인트 전에 전원이 나가면 마지막 트랜잭션이 빠질 수 있다 |
|
|
234
236
|
| `busy_timeout` | `5000` (밀리초) | 잠금을 만나면 바로 실패하지 않고 5초 기다린다. Python `sqlite3` 의 `timeout` 기본 5초와 같은 값 |
|
|
235
237
|
|
|
236
|
-
건드리지 않는 것: `foreign_keys` 는 Django 가 이미 켠다. `wal_autocheckpoint` 는 Litestream 이 체크포인트를 관리하므로 바꾸지 않는다. `temp_store`, `mmap_size`, `cache_size`, `journal_size_limit` 는 기본값이 아니다. 회귀 랩 벤치에서 개선이 반복해서 재현된 것이 없고, `cache_size=-65536` 만 켜면 오히려 처리량·p99 가 반복해서 나빠졌다(`CONN_MAX_AGE` 기본 0, [결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md#pragma-벤치)). `CONN_MAX_AGE=None`·WAL 이 자라는 부하로 다시 잰 [재측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md)에서도 개선은 재현되지 않았다. `cache_size` 를 올릴 때는 자기 부하로 잰다. 최소 재현([원인 조사](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/cache-size-2026-10-12.md))에서 64 MiB 로 올리면 방향이 부하에 따라 갈렸다. 연결당 요청 1~10개의 혼합 부하(인덱스 없는 정렬 10%), 연결 하나에서 많은 페이지를 한 번씩만 읽는 요청, 여러 연결이 쓰는 재사용 연결에서는 처리량이 크게 떨어졌다(8 MiB 도 같음). 캐시가 상한에 닿기 전에는 페이지 버퍼를 재활용하지 않고 새로 할당·해제하기 때문이다. 반대로 한 연결(요청·트랜잭션) 안에서 같은 페이지를 다시 읽는 요청과 다른 연결의 쓰기가 없는 재사용 연결에서는 좋아졌다. `journal_size_limit` 은 Litestream 과 함께 쓰면 앱에서 걸어도 WAL 크기를 거의 묶지 못했다(64 MiB 로 잘린 뒤 다시 자람). SQLite 는 WAL 재시작 뒤 첫 커밋한 연결의 한도로만 자르고 Litestream 은 체크포인트 직후 자기 연결로 쓰기 때문으로 본다(구조는 소스로 확인, 어느 연결이 먼저 커밋했는지는 관측하지 않음). 필요하면 `pragmas` 로 직접 켜고 자기 부하로 잰다.
|
|
238
|
+
건드리지 않는 것: `foreign_keys` 는 Django 가 이미 켠다. `wal_autocheckpoint` 는 Litestream 이 체크포인트를 관리하므로 바꾸지 않는다. 랩에서 1000(기본)과 0 을 견줬을 때 프로필 그대로는 빠진 커밋·WAL 되감김·WAL 크기에 차이가 없었고, 0 은 연결을 쥔 앱에서 Litestream 이 없을 때 WAL 을 계속 키웠다([#106 기록](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-12-wal-autocheckpoint.md)). `temp_store`, `mmap_size`, `cache_size`, `journal_size_limit` 는 기본값이 아니다. 회귀 랩 벤치에서 개선이 반복해서 재현된 것이 없고, `cache_size=-65536` 만 켜면 오히려 처리량·p99 가 반복해서 나빠졌다(`CONN_MAX_AGE` 기본 0, [결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md#pragma-벤치)). `CONN_MAX_AGE=None`·WAL 이 자라는 부하로 다시 잰 [재측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md)에서도 개선은 재현되지 않았다. `cache_size` 를 올릴 때는 자기 부하로 잰다. 최소 재현([원인 조사](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/cache-size-2026-10-12.md))에서 64 MiB 로 올리면 방향이 부하에 따라 갈렸다. 연결당 요청 1~10개의 혼합 부하(인덱스 없는 정렬 10%), 연결 하나에서 많은 페이지를 한 번씩만 읽는 요청, 여러 연결이 쓰는 재사용 연결에서는 처리량이 크게 떨어졌다(8 MiB 도 같음). 캐시가 상한에 닿기 전에는 페이지 버퍼를 재활용하지 않고 새로 할당·해제하기 때문이다. 반대로 한 연결(요청·트랜잭션) 안에서 같은 페이지를 다시 읽는 요청과 다른 연결의 쓰기가 없는 재사용 연결에서는 좋아졌다. `journal_size_limit` 은 Litestream 과 함께 쓰면 앱에서 걸어도 WAL 크기를 거의 묶지 못했다(64 MiB 로 잘린 뒤 다시 자람). SQLite 는 WAL 재시작 뒤 첫 커밋한 연결의 한도로만 자르고 Litestream 은 체크포인트 직후 자기 연결로 쓰기 때문으로 본다(구조는 소스로 확인, 어느 연결이 먼저 커밋했는지는 관측하지 않음). 필요하면 `pragmas` 로 직접 켜고 자기 부하로 잰다.
|
|
237
239
|
|
|
238
240
|
#### 프로필
|
|
239
241
|
|
|
@@ -346,6 +348,7 @@ python manage.py check --deploy # W001·W002·W004 는 --deploy 일 때만 나
|
|
|
346
348
|
| `sqlite_ops.W002` | `OPTIONS["init_command"]` 에서 `journal_mode` 가 `WAL` 로 설정되지 않음, 또는 `journal_mode` 를 언급하는 문장의 형식을 판정할 수 없음 | `--deploy` | `sqlite_database()` 를 쓰거나 `init_command` 에 `PRAGMA journal_mode=WAL` 을 넣는다 |
|
|
347
349
|
| `sqlite_ops.W003` | 이름이 Litestream VFS(`vfs=litestream` 이 든 `file:` URI)인 별칭의 `CONN_MAX_AGE` 가 `None` 이 아님, 그리고 [ASGI 근거](#asgi-판단)가 없음(WSGI 로 본다) | 항상 | 그 별칭에 `"CONN_MAX_AGE": None`. WSGI 실측에서 요청당 1,008ms → 1.7ms |
|
|
348
350
|
| `sqlite_ops.W005` | VFS 가 아닌 SQLite 별칭의 `CONN_MAX_AGE` 가 `None`, 그리고 [ASGI 근거](#asgi-판단)가 있음 | 항상 | 그 별칭의 `"CONN_MAX_AGE"` 를 `0` 으로 하거나 키를 지운다. ASGI 동기 뷰에서는 연결이 재사용되지 않고 닫히지도 않는다([문제 해결](#설정체크)) |
|
|
351
|
+
| `sqlite_ops.W006` | SQLite 별칭의 `ATOMIC_REQUESTS` 가 참이고 `OPTIONS["transaction_mode"]` 가 `IMMEDIATE` 또는 `EXCLUSIVE`(대소문자 무시). 읽기 전용·VFS·메모리 DB 별칭은 건너뛰고, 역할을 판정할 수 없는 별칭(W004)에는 낸다 | 항상 | 그 별칭의 `ATOMIC_REQUESTS` 를 끄고 쓰는 뷰에만 `transaction.atomic()` 을 쓰거나, 읽기 뷰에 `@transaction.non_atomic_requests(using="<별칭>")` 를 붙인다. 의도한 구성이면 `sqlite_ops.W006` 을 끈다([아래](#atomic_requests-와-immediate)) |
|
|
349
352
|
| `sqlite_ops.W004` | `NAME` 의 URI 로 별칭 역할(쓰기·읽기 전용·메모리·VFS)을 판정할 수 없음. 이때 W001·W002 는 내지 않는다 | `--deploy` | authority 는 비우거나 `localhost` 로(`file:///path`), `%00` 을 빼고, URI 쿼리 키 `mode`·`immutable`·`vfs` 를 한 번씩, 표준 표기(`mode=ro\|rw\|rwc\|memory`, `immutable=1\|0`)로 쓴다 |
|
|
350
353
|
|
|
351
354
|
- 기준값은 `sqlite_database()` 와 같은 권장값 표에서 읽는다. 프로필은 `SQLITE_OPS["PROFILE"]` 이고 없으면 `"single-server"` 다.
|
|
@@ -358,6 +361,7 @@ python manage.py check --deploy # W001·W002·W004 는 --deploy 일 때만 나
|
|
|
358
361
|
- Litestream VFS(`vfs=litestream`): W001·W002 를 건너뛴다. W003 은 그대로 본다.
|
|
359
362
|
- **판정할 수 없음 → W004** — `mode`·`immutable`·`vfs` 가 두 번 이상 나오거나(`mode=rwc&mode=ro`), `mode` 가 `ro`·`rw`·`rwc`·`memory` 가 아니거나(`mode=RO`, `mode=ro%00x`), `immutable` 이 위 불리언 표기가 아닐 때(`immutable=2`), 디코딩한 파일명이나 쿼리 키·값에 NUL(`%00`)이 있을 때(`mode%00x=ro` — SQLite 는 NUL 앞까지만 읽어 역할 키가 숨는다. URI 가 아닌 일반 경로의 `%00` 은 글자 그대로라 해당 없다), `file://` 뒤 authority 가 빈 값이나 정확히 `localhost` 가 아닐 때(`file://example.com/…`, `file://LOCALHOST/…` — SQLite 는 연결 오류를 낸다. `file://localhost/srv/app.sqlite3`·`file:///srv/app.sqlite3` 는 로컬 파일이다). 이때는 W001·W002 를 내지 않는다. SQLite 는 중복 키를 순서에 따라 다르게 해석해서(`mode=rwc&mode=ro` 는 읽기 전용, `mode=ro&mode=rwc` 는 연결 오류) 쓰기 권고를 따르면 연결이 깨질 수 있기 때문이다. W004 는 쓰기 설정을 넣어도 사라지지 않는다. 쿼리 키를 한 번씩, 표준 표기로 고쳐 쓴다.
|
|
360
363
|
- W003 은 ASGI 에서는 내지 않는다. ASGI 의 VFS 별칭 영속 연결은 아직 재지 않았고, Django 는 async 에서 영속 연결을 끄라고 권한다.
|
|
364
|
+
- W006 은 `ATOMIC_REQUESTS` 와 `transaction_mode=IMMEDIATE` 를 함께 쓴 경우다. 읽기만 하는 뷰도 직렬로 돈다([아래](#atomic_requests-와-immediate)).
|
|
361
365
|
- W005 는 ASGI 에서 일반(VFS 아닌) 별칭에 `CONN_MAX_AGE=None` 을 준 경우다. uvicorn·Django 6.1 동기 뷰 실측에서 DB 요청마다 연결이 새로 열렸고(재사용 없음), 요청이 끝나도 닫히지 않아 열린 DB 파일 디스크립터가 810–982개(한도 1024)까지 쌓이며 `unable to open database file` 500 이 났다. fd 고갈이 원인이라는 것은 추정이다([재측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md#결과-연결-재사용)).
|
|
362
366
|
|
|
363
367
|
#### ASGI 판단
|
|
@@ -374,6 +378,12 @@ W003 과 W005 는 같은 규칙으로 ASGI 인지 판단한다. 한 설정에서
|
|
|
374
378
|
- 잘못 잡는 경우: 프로필을 적었거나 `ASGI_APPLICATION` 이 있는데 실제로는 gunicorn(WSGI)으로 띄우면 ASGI 로 본다. W005 가 나고, VFS 별칭의 W003 은 나지 않는다. 이때는 `SILENCED_SYSTEM_CHECKS = ["sqlite_ops.W005"]` 로 끈다.
|
|
375
379
|
- 경고를 끄려면 Django 표준 `SILENCED_SYSTEM_CHECKS` 를 쓴다. 이 앱의 체크만 돌리려면 `check --tag sqlite_ops`.
|
|
376
380
|
|
|
381
|
+
#### `ATOMIC_REQUESTS` 와 `IMMEDIATE`
|
|
382
|
+
|
|
383
|
+
W006 은 `ATOMIC_REQUESTS=True` 를 권장 `transaction_mode=IMMEDIATE` 와 함께 쓴 경우다. Django 는 그 별칭의 뷰마다 `atomic()` 을 씌우고, `IMMEDIATE` 에서는 그 `atomic()` 이 `BEGIN IMMEDIATE` 로 시작해 뷰가 끝날 때까지 쓰기 잠금을 쥔다. SQLite 의 쓰기 잠금은 하나라서 **읽기만 하는 뷰도 서로를 기다린다.** 읽기만 하고 0.3초 쉬는 뷰 4개를 동시에 부르면 요청별 0.32·0.67·0.99·1.31초가 걸렸다(`ATOMIC_REQUESTS` 를 끄면 모두 0.32초, Django 6.1.2·SQLite 3.53.1·WAL, 테스트 클라이언트 스레드 4개, #102 재현함). `transaction_mode` 를 `DEFERRED` 로 바꾸는 것은 대처로 권하지 않는다. 쓰기 경합에서 `database is locked` 를 피하려는 권장값이고, 바꾸면 W001 이 난다. 대처는 `ATOMIC_REQUESTS` 를 끄고 쓰는 뷰에만 `transaction.atomic()` 을 쓰거나, 읽기 뷰에 `@transaction.non_atomic_requests(using="<별칭>")` 를 붙이는 것이다. 의도한 구성이면 `SILENCED_SYSTEM_CHECKS = ["sqlite_ops.W006"]` 로 끈다. `check --fail-level WARNING` 을 쓰는 CI 는 이 조합에서 실패한다.
|
|
384
|
+
|
|
385
|
+
메모리 DB 별칭에는 W006 을 내지 않는다. `:memory:` 는 연결마다 DB 가 따로라 잠금을 다투지 않고(같은 측정에서 4개 모두 0.32초), 공유 캐시 메모리 DB(`file::memory:?cache=shared`, `mode=memory&cache=shared`)는 기다리지 않고 바로 실패했다(4개 중 3개가 500, 재현함). 후자는 직렬화와 다른 현상이라 이 경고로 다루지 않는다. 읽기 전용·VFS 별칭은 쓰지 않으므로 내지 않는다.
|
|
386
|
+
|
|
377
387
|
```python
|
|
378
388
|
# settings.py — 직접 쓴 설정도 같은 기준으로 검사된다
|
|
379
389
|
INSTALLED_APPS = ["django_sqlite_ops"]
|
|
@@ -406,7 +416,7 @@ python manage.py sqlite_doctor --json # 모니터링·스크립트용
|
|
|
406
416
|
|---|---|
|
|
407
417
|
| `--database ALIAS` | 볼 별칭(여러 번 줄 수 있다). 없으면 sqlite3 엔진 별칭 전부 |
|
|
408
418
|
| `--litestream-config PATH` | Litestream 설정 파일. 주면 그 DB 목록을 `DATABASES` 와 대조한다. 없으면 이 섹션은 건너뛴다. 상대 경로는 명령을 실행한 디렉터리 기준이다 |
|
|
409
|
-
| `--litestream BIN` | Litestream 바이너리(기본 PATH 의 `litestream`, 0.5.17 만 받는다) |
|
|
419
|
+
| `--litestream BIN` | Litestream 바이너리(기본 PATH 의 `litestream`, 0.5.17·0.5.18 만 받는다) |
|
|
410
420
|
| `--json` | JSON 출력(스키마 `version: 1`, [DESIGN §6-2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md)). 열린 파일 한도는 `section` `process`, `key` `nofile_soft`·`nofile_hard`(값은 정수 또는 `"unlimited"`) 또는 `nofile`(값 `null`, 읽지 못함) |
|
|
411
421
|
|
|
412
422
|
> **주의 — 연결하면 `init_command` 가 실행되고 `-wal`·`-shm` 이 생길 수 있다.** Django 는 연결할 때마다 `OPTIONS["init_command"]` 를 실행하므로 진단이 DB 를 바꿀 수 있다. 예를 들어 `PRAGMA journal_mode=WAL` 은 파일에 남는다. 또 WAL 모드 DB 는 연결만 해도 SQLite 가 `-wal`·`-shm` 파일을 만든다(`init_command` 가 없어도). 쓰기 별칭은 앱이 연결할 때와 같은 일이므로 그대로 연결한다. 운영 DB 에서 돌린다는 점을 알고 실행한다.
|
|
@@ -417,7 +427,7 @@ python manage.py sqlite_doctor --json # 모니터링·스크립트용
|
|
|
417
427
|
|
|
418
428
|
무엇을 보나:
|
|
419
429
|
|
|
420
|
-
- **database** — 별칭마다 Django 가 실제로 여는 경로(`OPTIONS["database"]` 가 있으면 `NAME` 대신 그것. 연결하지 않고 Django 의 연결 매개변수에서 읽는다)를 기준으로 역할(쓰기·읽기 전용·메모리·VFS), 파일 크기, `-wal` 크기(연결 전), 실제 `transaction_mode`·`journal_mode`·`synchronous`·`busy_timeout`·`foreign_keys`·SQLite 버전. [권장값](#기본값)과 다르면 `warn`. 읽기 전용 별칭은 쓰기 권고(`transaction_mode`·`journal_mode`·`synchronous`)를 비교하지 않는다. 메모리 DB 는 "메모리 DB" 로만 적는다. Litestream VFS 별칭과 역할을 판정할 수 없는 별칭(W004, 로컬이 아닌 URI authority 포함)은 연결하지 않고 `unknown`. 연결 실패는 `error`.
|
|
430
|
+
- **database** — 별칭마다 Django 가 실제로 여는 경로(`OPTIONS["database"]` 가 있으면 `NAME` 대신 그것. 연결하지 않고 Django 의 연결 매개변수에서 읽는다)를 기준으로 역할(쓰기·읽기 전용·메모리·VFS), 파일 크기, `-wal` 크기(연결 전), 실제 `transaction_mode`·`journal_mode`·`synchronous`·`busy_timeout`·`foreign_keys`·SQLite 버전. [권장값](#기본값)과 다르면 `warn`. SQLite 버전이 [WAL-reset 버그](#sqlite-wal-reset-버그)의 영향 범위이면 `sqlite_version` 줄에 그 사실을 메시지로 적는다. **보고만 하므로 수준은 `ok` 이고 종료 코드를 바꾸지 않는다.** 읽기 전용 별칭은 쓰기 권고(`transaction_mode`·`journal_mode`·`synchronous`)를 비교하지 않는다. 메모리 DB 는 "메모리 DB" 로만 적는다. Litestream VFS 별칭과 역할을 판정할 수 없는 별칭(W004, 로컬이 아닌 URI authority 포함)은 연결하지 않고 `unknown`. 연결 실패는 `error`.
|
|
421
431
|
- **mount** — 위의 실제 경로로 DB 파일(없으면 존재하는 상위 디렉터리)의 파일시스템 종류. NFS·SMB/CIFS·AFP·sshfs 같은 네트워크 파일시스템이면 `warn` 이다. SQLite 의 파일 잠금을 믿을 수 없고 WAL 이 동작하지 않는다([SQLite 문서](https://www.sqlite.org/useovernet.html)). 로컬로 확인되지 않은 종류(알 수 없는 FUSE 등)는 `unknown`.
|
|
422
432
|
- **litestream** — `litestream databases -config PATH -json` 으로 설정의 DB 목록을 읽어 실제 경로로 대조한다. 쓰기 별칭이 설정에 없으면 `warn`(복제되지 않음). 단 channels-lite 전용 채널 DB(앱 DB 와 다른 파일)는 복제에서 빼는 것이 규칙이라 이 경고를 내지 않는다. 설정 경로가 실제 경로가 아니면(부모에 링크) `warn` — boot 가 거부하는 구성이다([경로 규칙](#경로-규칙)). 상대 경로·`dir:` 항목은 Litestream 의 작업 디렉터리 기준으로 풀려 대조할 수 없으므로 `warn`. 설정에만 있는 DB 는 `ok`(정보). 설정 파일 없음·깨진 YAML·바이너리 없음은 `error`.
|
|
423
433
|
- **channels** — `CHANNEL_LAYERS` 를 읽기만 한다(`channels` 를 import 하지 않는다. 예외는 아래 `patch` 항목). 백엔드별 의미론을 한 줄로 요약한다. `InMemoryChannelLayer` 는 프로필이 `single-server-multiproc` 이면 `warn`. channels-lite 는 채널 DB 가 앱 DB 와 같은 파일이면 `warn`, Litestream 설정의 복제 대상이면 `warn`. 모르는 백엔드(`channels_redis` pub/sub 포함)는 `unknown`. `AIOSQLiteChannelLayer` 를 쓰거나 `PATCH_CHANNELS_LITE_AIO` 가 켜져 있으면 [중복 배달 패치](#channels-lite-패치) 상태를 `patch` 항목으로 보여 준다. 이때만 channels-lite 를 import 한다(DB 는 열지 않는다).
|
|
@@ -564,7 +574,7 @@ WantedBy=timers.target
|
|
|
564
574
|
|
|
565
575
|
컨테이너가 뜰 때 Litestream 복제본과 로컬 DB 를 비교해 **쓸 수 있으면 그대로, 볼륨이 비었으면 복원, 판정할 수 없으면 기동을 거부**한 뒤 다음 명령으로 넘어간다. Django 를 import 하지 않는 독립 CLI 다. `manage.py` 명령은 `django.setup()` 중에 빈 DB 파일을 만들 수 있고, 그러면 복원이 건너뛰어지기 때문이다([DESIGN §4-1](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md)).
|
|
566
576
|
|
|
567
|
-
필요한 것: POSIX(Linux·macOS), PATH 의 `litestream` 0.5.17(검증한 버전만
|
|
577
|
+
필요한 것: POSIX(Linux·macOS), PATH 의 `litestream` 0.5.17 또는 0.5.18(검증한 버전만 받는다, 2026-11-12 확인), DB 마다 복제본이 적힌 Litestream 설정 파일. 설치는 [공식 문서](https://litestream.io/install/)를 따른다.
|
|
568
578
|
|
|
569
579
|
#### 실행 형태
|
|
570
580
|
|
|
@@ -652,10 +662,12 @@ DB 파일이 0바이트면 도입할 데이터가 없으므로 `--adopt-existing
|
|
|
652
662
|
|
|
653
663
|
로그의 `decision: ... reason_code=...` 와 그 아래 `hint:` 줄을 본다.
|
|
654
664
|
|
|
665
|
+
원격 조회가 실패했으면(사유에 `remote lookup failed` 또는 `remote error:`) 사유 코드가 무엇이든 `hint:` 줄이 조회 실패의 원인과 대처(버전 게이트 실패면 판·바이너리 안내, 아니면 복제본 접근 안내)로 시작한다. 사유 코드가 `remote_error` 가 아니면(`stale_meta`·`empty_db`·`orphan_sidecars`) 같은 줄의 `then:` 뒤에 조회가 된 뒤 할 일이 온다. 조회가 실패한 동안은 `--on-unknown restore` 로 다시 돌려도 같은 거부다([D-12](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) — 조회부터 고친다.
|
|
666
|
+
|
|
655
667
|
| 사유 코드 | 뜻 | 대처 |
|
|
656
668
|
|---|---|---|
|
|
657
669
|
| `no_replica_no_local` | 볼륨도 복제본도 비었다 | 첫 배포면 `--init-new` 를 한 번. 아니면 설정의 복제본 경로·prefix 를 확인한다 |
|
|
658
|
-
| `remote_error` | 복제본 조회
|
|
670
|
+
| `remote_error` | 복제본 조회 실패·타임아웃. Litestream 버전 게이트 실패(검증하지 않은 판, 바이너리를 실행할 수 없음, `litestream version` 출력을 읽을 수 없음)도 여기 든다 | 네트워크·자격 증명·endpoint(평문 HTTP 면 `http://` 를 붙인다)를 고친다. 버전 게이트 실패면 `hint:` 줄이 그 원인(설치된 판·검증한 판·바이너리 경로)을 말하니 검증한 판을 설치하거나 `--litestream` 으로 그 바이너리를 가리킨다([문제 해결 boot](#boot)). 이 경우는 `--on-unknown` 과 무관하게 거부한다([D-12](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
|
|
659
671
|
| `remote_ahead` | 복제본이 이 볼륨보다 새롭다(옛 볼륨으로 재부팅) | 복제본이 정본이면 `--on-unknown restore` 로 한 번 부팅한다. 로컬은 `<db>.stale-<ts>/` 에 남는다 |
|
|
660
672
|
| `no_local_meta` | DB 는 있는데 Litestream 메타가 없다 | 아래 '복원 직후 크래시 복구'. 기존 DB 를 처음 올리는 중이고 복제본이 비었으면 `--adopt-existing`. 사유에 `local db is 0 bytes` 가 있으면 아래 '0바이트 DB 복구'의 '메타가 없을 때'([D-18](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
|
|
661
673
|
| `stale_meta` | DB 없이 메타만 남았다 | `--on-unknown restore` 로 메타를 격리하고 복원한다 |
|
|
@@ -713,10 +725,11 @@ python -m django_sqlite_ops.boot --db /data/app.sqlite3 --config /etc/litestream
|
|
|
713
725
|
|
|
714
726
|
### 복제 헬스
|
|
715
727
|
|
|
716
|
-
"지금 복제가 따라오는가"를 `caught_up / backlog / unknown` 으로 보고하는 JSON 엔드포인트다. Litestream 은 S3 가 끊겨도 로그·`status`·메트릭에 아무것도 남기지
|
|
728
|
+
"지금 복제가 따라오는가"를 `caught_up / backlog / unknown` 으로 보고하는 JSON 엔드포인트다. Litestream 은 S3 가 끊겨도 로그·`status`·메트릭에 아무것도 남기지 않을 수 있으므로(실측 D3: 0.5.17 은 0줄. 0.5.18 은 연결 거부 때만 ERROR 줄을 남기고 응답 없는 S3 에는 0줄이다, #99), 헬스는 Litestream 의 자기 보고 대신 세 가지를 직접 본다.
|
|
717
729
|
|
|
718
730
|
1. **TXID 비교 (로컬 L0 → 복제본)** — 로컬 메타의 최대 TXID(`.<db>-litestream/ltx/0/`)와 복제본의 최대 TXID(`litestream ltx -level all -json`). 업로드가 막히면(S3 끊김) 로컬이 앞선다.
|
|
719
731
|
2. **WAL 위치 비교 (DB → 로컬 L0)** — 최신 로컬 L0 파일의 헤더에는 그 L0 가 원래 WAL 의 어디까지를 담았는지(WAL 오프셋·크기·salt)가 있다. 현재 `-wal` 파일의 헤더와 프레임 헤더를 읽어(salt·누적 체크섬이 맞는 프레임만) 그 위치 **뒤에 커밋이 있는지** 본다. Litestream 자신도 다음 동기화를 이 위치에서 이어 간다. `litestream replicate` 가 죽거나 멈추면 TXID 는 로컬·원격이 같은 채로 멈춰 1 만으로는 그 뒤의 쓰기가 보이지 않는데, 2 가 그것을 잡는다.
|
|
732
|
+
3. **컴팩션 진행 (복제본 L0 → L1)** — 1 과 같은 `ltx -level all -json` 출력에서, L1 이 아직 덮지 않은 가장 오래된 L0 가 복제본의 가장 새 객체보다 얼마나 오래됐는지 본다(추가 조회 없음). 로컬 L0 하나가 깨지면 L1 컴팩션만 멈추고 L0 업로드는 계속되어 1·2 는 정상으로 보인다(#106·#115 재현함). 3 이 그것을 잡는다.
|
|
720
733
|
|
|
721
734
|
**DB 연결을 열지 않는다.** 모두 파일 읽기(`-wal` 은 읽기 전용으로 열어 헤더만)와 `litestream` 명령으로만 본다.
|
|
722
735
|
|
|
@@ -739,6 +752,7 @@ SQLITE_OPS = {
|
|
|
739
752
|
},
|
|
740
753
|
"REFRESH": 15, # 초, 원격 조회 주기 (기본 15)
|
|
741
754
|
"BACKLOG_GRACE": 60, # 초, 로컬이 앞선 상태를 backlog 로 볼 때까지 (기본 60)
|
|
755
|
+
"COMPACTION_GRACE": 300, # 초, 컴팩션 지연을 backlog 로 볼 때까지 (기본 300, None 이면 끔)
|
|
742
756
|
},
|
|
743
757
|
}
|
|
744
758
|
```
|
|
@@ -793,21 +807,25 @@ urlpatterns = [
|
|
|
793
807
|
| `caught_up` | `local_ahead_within_grace` | 로컬 TXID 가 앞서 있지만 아직 올라가지 않은 TXID 가 기다린 시간이 `BACKLOG_GRACE` 보다 짧음(정상 업로드 지연). `backlog_since` 가 함께 나온다 |
|
|
794
808
|
| `caught_up` | `db_changed_within_grace` | `-wal` 에 최신 L0 뒤의 커밋이 있지만 `BACKLOG_GRACE` 보다 짧음(Litestream 이 곧 L0 를 쓴다). `pending_since` 가 함께 나온다 |
|
|
795
809
|
| `backlog` | `local_ahead` | 아직 올라가지 않은 로컬 TXID 가 `BACKLOG_GRACE` 이상 기다림. 업로드가 막혔거나 쓰기를 못 따라간다(S3 끊김, 자격 증명 만료) |
|
|
796
|
-
| `backlog` | `db_not_replicated` | `-wal` 에
|
|
810
|
+
| `backlog` | `db_not_replicated` | 최신 로컬 L0 가 그대로인 채 `-wal` 에 그 뒤의 커밋이 `BACKLOG_GRACE` 이상 남아 있음(WAL 근거가 없는 표본에서는 파일 시각이 그만큼 L0 보다 새로움 — 두 근거가 번갈아 나와도 이어서 센다). `litestream replicate` 가 죽었거나 멈췄거나, 최신 로컬 L0 가 깨져 sync 가 멈췄을 수 있다(0.5.17) |
|
|
811
|
+
| `backlog` | `compaction_stalled` | 다른 판정이 `caught_up` 인데, L1 이 덮지 않은 가장 오래된 원격 L0 가 가장 새 원격 객체보다 `COMPACTION_GRACE` 이상 오래됨. L1 컴팩션이 멈췄다(로컬 L0 손상 등). 복원은 아직 되지만 L0 가 쌓이며 복제본이 커진다. 사유에 미컴팩션 L0 수와 L1 끝 TXID 가 나온다. Litestream 로그의 `compaction failed` 를 본다 |
|
|
797
812
|
| `unknown` | `no_wal_evidence` | `-wal` 로 판정할 수 없음: `-wal` 이 없거나(마지막 연결이 닫혀 지워짐) 비었음, L0 이후 WAL 이 다시 시작됐는데 아직 커밋이 없음 등. **이때는 `caught_up` 으로 단정하지 않는다** |
|
|
813
|
+
| `unknown` | `no_compaction_evidence` | 다른 판정이 `caught_up` 인데 `ltx` 출력의 `timestamp` 를 읽을 수 없어 컴팩션 진행을 판정할 수 없음(검증한 판에서는 나오지 않는다 — 형식이 바뀐 경우의 방어) |
|
|
798
814
|
| `unknown` | `file_time_backwards` | WAL 근거가 없는데 DB·`-wal`·L0 의 파일 시각이 앞선 관측보다 뒤로 감(시계 변경 등) |
|
|
799
815
|
| `unknown` | `db_missing` · `db_empty` · `db_not_regular` · `db_stat_failed` | DB 본 파일이 없음 · 0바이트 · 정규 파일이 아님(디렉터리 등) · 상태를 읽을 수 없음. `-wal`·Litestream 메타·복제본이 맞아도 그것이 붙을 DB 가 없으므로 정상이 아니다. 아래 [DB 본 파일](#db-본-파일-상태) 참고 |
|
|
800
816
|
| `unknown` | `remote_error` · `remote_empty` · `no_local_meta` · `remote_ahead` · `unknown_at_boot` · `path_not_real` · `not_file_db` · `refresh_failed` · `not_checked` · `stale` | 판정할 수 없음: 원격 조회 실패, 복제본이 빈 목록(경로·prefix 오타와 구분되지 않는다), 로컬 메타 없음(`litestream replicate` 가 돌지 않음), **복제본이 로컬보다 앞섬**(다른 기계가 같은 복제본에 쓰는 중일 수 있다), 부팅 상태 파일의 `unknown_at_boot`(boot 가 `--on-unknown keep-local` 로 진행함), [경로 규칙](#경로-규칙) 위반, 파일 DB 가 아님, 갱신 중 예외, 아직 첫 조회 전, 마지막 결과가 `REFRESH × 3` 보다 오래됨(갱신 스레드가 멈춤) |
|
|
801
817
|
|
|
802
818
|
`local_ahead` 의 backlog 시간은 **처음 관측한 미업로드 로컬 TXID 가 원격에 올라가기까지 기다린 시간**이다. 가장 오래된 미업로드 커밋의 엄밀한 나이나 로컬·원격의 총 격차가 아니다.
|
|
803
819
|
|
|
820
|
+
**`backlog` 는 "복제 파이프라인이 제때 진행하지 않는 것을 안다"는 뜻이다** — 업로드 지연(`local_ahead`), L0 기록 지연(`db_not_replicated`), 컴팩션 정지(`compaction_stalled`). 데이터는 아직 복제본에 있지만 그대로 두면 나빠진다. `caught_up` 은 "TXID·WAL 위치가 맞고 **파이프라인이 멈췄다는 근거가 없다**"이다. 한계: 쓰기가 없는 동안 Litestream 이 죽은 것은 `caught_up` 으로 남는다(복제할 것이 없다). 다음 쓰기에서 `BACKLOG_GRACE` 안에 `db_not_replicated` 로 드러난다.
|
|
821
|
+
|
|
804
822
|
`code` 는 고정된 값이라 알람 규칙에 쓸 수 있다. 설정이 없거나 틀리면 최상위에 `code`(`not_configured`·`invalid_config`)와 `reason` 이 붙는다.
|
|
805
823
|
|
|
806
824
|
**WAL 위치 근거** (형식: [SQLite WAL](https://www.sqlite.org/fileformat2.html#walformat), superfly/ltx v0.5.2 `Header`, Litestream 0.5.17 `db.go`):
|
|
807
825
|
- 판정: 현재 `-wal` 의 salt 가 최신 L0 의 salt 와 같으면, L0 끝(`WALOffset + WALSize`) 뒤에 유효한 커밋 프레임이 있으면 pending, 없으면 in_sync. salt 가 다르면(L0 이후 WAL 이 다시 시작됨) 새 salt 의 커밋이 있으면 pending, 없으면 판정할 수 없음.
|
|
808
826
|
- 프레임은 salt 와 누적 체크섬이 맞을 때만 센다(SQLite 의 복구 규칙과 같다). 쓰다 만 프레임은 커밋으로 세지 않는다. L0 끝 뒤의 프레임만, 첫 커밋까지만 읽는다(한 번에 최대 64 MiB).
|
|
809
827
|
- 잠금 없이 읽으므로 읽는 도중 체크포인트가 `-wal` 을 비우거나 다시 시작할 수 있다. 그래서 "L0 뒤 커밋 없음"이라고 답하기 전에 처음 연 `-wal` 이 그대로인지(같은 파일, 같은 크기, 같은 헤더) 다시 보고, 바뀌었으면 한 번만 다시 읽는다. 그래도 바뀌면 판정하지 않는다(`no_wal_evidence`).
|
|
810
|
-
- pending 지속 시간은 처음 관측한 때부터 monotonic 으로 세고, 최신 L0
|
|
828
|
+
- pending 지속 시간은 처음 관측한 때부터 monotonic 으로 세고, 최신 L0 가 바뀌면(Litestream 이 진행 중) 다시 센다. 최신 L0 가 그대로이면 WAL 근거와 파일 시각 근거가 번갈아 나와도 이어서 센다(#115 — 요청마다 연결을 닫는 앱에서 Litestream 이 없으면 `-wal` 이 지워졌다 생기며 두 근거가 번갈아 나온다. 예전에는 그때마다 다시 세어 `backlog` 가 되지 않았다, #106 재현함). 쓰기가 계속되는 바쁜 DB 도 Litestream 이 살아 있으면 `backlog` 가 되지 않는다(실측: 30ms 간격 쓰기 30초, 0.5초마다 판정, grace 3초에서 모두 `caught_up`).
|
|
811
829
|
- Litestream 이 도는 동안에는 Litestream 이 연결을 쥐고 있어 `-wal` 이 남으므로 이 근거가 늘 있다.
|
|
812
830
|
|
|
813
831
|
**보조 근거: 파일 시각** — WAL 로 판정할 수 없을 때만 쓴다(주로 Litestream 이 멈춘 뒤 앱의 마지막 연결이 닫혀 `-wal` 이 지워졌을 때).
|
|
@@ -816,9 +834,16 @@ urlpatterns = [
|
|
|
816
834
|
- 파일 시각이 앞선 관측보다 뒤로 가면(벽시계 역행, 파일 복사) `unknown`(`file_time_backwards`)이고 앞선 근거를 지우지 않는다. 다른 프로세스가 DB 파일을 `touch`·복사·덮어쓰면 이 경로는 틀릴 수 있다.
|
|
817
835
|
- 드문 경우: Litestream 이 도는데 `-wal` 이 TRUNCATE 체크포인트로 0바이트가 되고 그 뒤 쓰기가 없으면(기본 설정에서는 WAL 이 약 500MB 를 넘을 때만) 다음 쓰기까지 `unknown` 이거나, 파일 시각 때문에 grace 뒤 `backlog` 로 보일 수 있다.
|
|
818
836
|
|
|
837
|
+
**컴팩션 진행** (#115, Litestream 0.5.17·0.5.18 소스·실측):
|
|
838
|
+
- 컴팩션 지연 = 복제본의 가장 새 객체 시각 − L1 이 덮지 않은 가장 오래된 L0 의 시각. 'L1 이 덮은 끝'은 레벨 1~8 의 최대 TXID 다(Litestream 자신도 L0 보존에서 L1 의 최대 TXID 를 쓴다). 스냅샷(L9)은 DB 의 현재 위치에서 새로 쓰이므로 넣지 않는다 — 넣으면 하루 한 번 정지를 가린다.
|
|
839
|
+
- 시각은 복제본 안의 시각끼리만 비교한다(S3 `LastModified`·파일 mtime). 로컬 시계와 비교하지 않으므로 시계 차이·앱 재시작과 무관하다.
|
|
840
|
+
- 정상 지연은 L1 주기에 묶인다: 기본 L1 30초에서 3~27초 톱니(0.5.17 로컬 파일 복제본, 쓰기 1회/초 — [기록](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-13-health-pipeline-stall.md)). 컴팩션이 멈추면 벽시계와 같은 속도로 늘어 기본 `COMPACTION_GRACE` 300초를 정지 뒤 약 5분에 넘는다.
|
|
841
|
+
- **Litestream 의 `levels` 에서 L1 `interval` 을 바꿨다면 `COMPACTION_GRACE` 를 그 10배 이상으로 둔다.** 헬스는 Litestream 설정을 읽지 않는다. L1 을 5분 이상으로 두면 기본값에서 `compaction_stalled` 가 오경보한다(코드상 예상, 미검증). 끄려면 `None`.
|
|
842
|
+
- L0 기록까지 멈추면(새 객체가 없음) 이 지연은 늘지 않는다. 그 경우는 위 WAL 근거가 `db_not_replicated` 로 잡는다.
|
|
843
|
+
|
|
819
844
|
- **전체 `status` 는 별칭 중 가장 나쁜 것**이다(`unknown` > `backlog` > `caught_up`). `unknown` 이 가장 나쁜 이유: 복제가 따라오는지조차 말할 수 없다는 뜻이라, 뒤처진 것을 아는 `backlog` 보다 더 큰 문제를 감출 수 있다.
|
|
820
845
|
- **요청마다 S3 를 부르지 않는다.** 프로세스마다 데몬 스레드 하나가 `REFRESH` 초마다 조회하고 뷰는 마지막 결과를 읽기만 한다. 스레드는 **첫 헬스 요청 때** 시작하므로 배포 직후 첫 응답은 `unknown`("not checked yet")이다. 관리 명령·마이그레이션에서는 스레드가 돌지 않는다. gunicorn `--preload` 처럼 포크하는 서버에서도 워커마다 PID 를 보고 다시 시작한다.
|
|
821
|
-
- **S3 가 끊기면 `code` 가 `stale` 과 `remote_error` 를 오간다.** 원격 조회(`litestream ltx`)는 S3 가
|
|
846
|
+
- **S3 가 끊기면 `code` 가 `stale` 과 `remote_error` 를 오간다.** 원격 조회(`litestream ltx`)는 S3 가 양방향으로 응답하지 않으면 30초 타임아웃까지 매달린다(바꿀 수 없다). S3 가 연결을 거부할 때는 판마다 다르다: 0.5.17 은 역시 30초 매달리고, 0.5.18 은 약 7초 뒤 스스로 실패한다(`exited with 1`). 조회가 끝나지 않은 동안 마지막 관측이 `REFRESH × 3` 보다 오래되면 `stale`, 실패로 끝난 직후에는 `remote_error` 이고, 단절이 이어지면 `REFRESH + 조회 시간`(30초 매달림이면 `REFRESH + 30`, 0.5.18 의 연결 거부면 약 `REFRESH + 7`) 초 주기로 번갈아 나온다(회귀 랩에서 재현함, [#65](https://github.com/itda-work/django-sqlite-ops/issues/65)·[#99](https://github.com/itda-work/django-sqlite-ops/issues/99)). 둘 다 `unknown` 이므로 경보는 `code` 가 아니라 `status` 로 건다. 갱신 스레드는 하나라 **별칭이 여럿이면 한 별칭의 매달림이 다른 별칭도 `stale` 로 만들 수 있다**(매달린 시간 30초가 `2 × REFRESH` 를 넘을 때, 즉 `REFRESH` 를 15초보다 줄였을 때 뚜렷하다). 이때 `stale` 의 `reason` 끝에 원인 별칭과 조회 경과 초가 나온다: `the refresh thread has been checking alias 'slow' for 31s`(자기 자신이면 `this alias`). 매달림이 끝나고 스레드가 `REFRESH` 를 쉬는 동안에는 `the last refresh spent 30s on alias 'slow'` 처럼 직전 갱신에서 가장 오래 걸린 조회가 나온다. 그 조회도 `REFRESH` 보다 짧았다면 `the refresh thread may be stuck` 이다(#72).
|
|
822
847
|
- **지속 시간(`backlog_since`·`pending_since` 부터의 시간, `REFRESH × 3` 판정)은 프로세스 메모리에서 monotonic 시계로 잰다.** 주 근거(TXID·WAL 위치)는 벽시계를 쓰지 않으므로 벽시계가 NTP 로 뒤로 가도 판정이 되돌아가지 않는다. 보조 근거(파일 시각)는 벽시계를 쓰므로 역행을 보면 `unknown` 으로 둔다. 응답의 시각 문자열(UTC, 밀리초)은 표시용 벽시계다. 앱을 재시작하면 추적이 초기화되어, 재시작 직후에는 오래된 backlog 도 `BACKLOG_GRACE` 동안 `caught_up` 으로 보인다. 워커마다 따로 추적하므로 워커별 응답이 몇 초 다를 수 있다.
|
|
823
848
|
- **`ATOMIC_REQUESTS` 를 켠 별칭이 있어도 헬스 요청은 트랜잭션으로 감싸지 않는다.** Django 는 감싸려고 뷰 실행 전에 연결을 연다(그러면 DB 파일이 생길 수 있다). `health_view` 는 모든 별칭에서 빠지도록 표시돼 있다. 헬스 뷰를 다른 데코레이터로 감쌀 때는 `functools.wraps` 로 이 표시(`_non_atomic_requests`)를 옮긴다.
|
|
824
849
|
- **"마지막 업로드 시각"은 쓰지 않는다.** 쓰기가 없는 정상 DB 도 업로드 시각은 오래되기 때문이다.
|
|
@@ -957,6 +982,7 @@ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른
|
|
|
957
982
|
| DB 파일 옆에 `-wal`·`-shm` 이 생긴다 | WAL 모드의 정상 동작. 연결이 열린 동안 커밋 일부는 `-wal` 에만 있다 | 복사·백업할 때 DB 파일만 따로 옮기지 않는다 | 재현함(`tests/test_readme.py` 가 연결 중 두 파일을 확인) |
|
|
958
983
|
| ASGI(uvicorn) 에서 부하가 몰리면 `OperationalError: unable to open database file` 로 500 이 난다 | `CONN_MAX_AGE=None` 을 줬다. ASGI 의 동기 뷰는 요청마다 새 스레드에서 돌아 연결이 재사용되지 않고, 닫히지 않은 연결이 파일 디스크립터를 쌓는다(동시 16·48 에서 DB fd 810–982개, 한도 1024). [시스템 체크](#시스템-체크) `sqlite_ops.W005` 가 이 설정을 알린다(ASGI 로 [판단](#asgi-판단)될 때) | 일반 별칭은 `CONN_MAX_AGE` 를 주지 않는다(기본 0). 재사용 이득도 없었다. fd 한도를 올리는 것은 대처가 아니다([fd 한도 가이드 §5](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md#5-한도를-올려도-해결되지-않는-것--conn_max_agenone--asgi)) | 재현함([재측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md#결과-연결-재사용). fd 고갈이 원인이라는 것은 추정). fd 한도를 1,048,576 으로 올리면 동시 16·30분에서 오류는 없었지만 fd 중앙값 1,370개·앱 RSS 중앙값 411.5 MiB 를 썼다(`0` 은 82개·73.2 MiB). 이득 없이 자원만 더 쓴다([장시간 실행](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/fd-soak-2026-10-09.md)) |
|
|
959
984
|
| 동시 접속(웹소켓·keep-alive)이 많을 때 `OSError: [Errno 24] Too many open files` 또는 `unable to open database file` | 프로세스의 열린 파일 한도(`RLIMIT_NOFILE` soft)에 닿았다. 한도를 주지 않은 컨테이너는 Docker 데몬 설정을 따른다(colima 에서 soft 1024) | `/proc/1/limits` 나 `sqlite_doctor` 의 `[process]` 로 현재 값을 보고, 피크 동시 접속으로 잰 fd 수에 여유를 더해 compose `ulimits`·`docker run --ulimit`·systemd `LimitNOFILE=` 로 올린다. hard 는 `fs.nr_open` 을 넘길 수 없다. 먼저 위 `CONN_MAX_AGE=None` 줄이 아닌지 본다([fd 한도 가이드](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md)) | 공식 문서([getrlimit(2)](https://man7.org/linux/man-pages/man2/getrlimit.2.html): 한도 초과는 `EMFILE`), 재현함(soft 를 64 로 낮춘 프로세스에서 `open` 은 `OSError(24, 'Too many open files')`, `sqlite3.connect` 는 `unable to open database file` — macOS. compose `ulimits` 적용·`nr_open` 초과 시 기동 실패 — colima, #32) |
|
|
985
|
+
| 읽기만 하는 뷰인데 동시 요청이 하나씩 처리된다(요청 수만큼 응답이 늦어진다) | `ATOMIC_REQUESTS=True` 와 `transaction_mode=IMMEDIATE` 를 함께 썼다. 뷰마다 `BEGIN IMMEDIATE` 가 쓰기 잠금을 뷰 끝까지 쥔다. [시스템 체크](#시스템-체크) `sqlite_ops.W006` 이 알린다 | `ATOMIC_REQUESTS` 를 끄고 쓰는 뷰에만 `transaction.atomic()`, 또는 읽기 뷰에 `@transaction.non_atomic_requests(using="<별칭>")`. `transaction_mode` 는 그대로 둔다([W006](#atomic_requests-와-immediate)) | 재현함(`tests/test_checks.py::test_w006_combination_serializes_read_views`: 0.3초 뷰 4개가 1.35초, 끄면 0.32초) |
|
|
960
986
|
| `manage.py check` 에 `sqlite_ops.*` 가 안 나온다 | 앱이 `INSTALLED_APPS` 에 없다. W001·W002·W004 는 `--deploy` 일 때만 나온다 | 앱을 넣고 `check --deploy` 로 돌린다([시스템 체크](#시스템-체크)) | 코드상 확인(`checks.py`) |
|
|
961
987
|
|
|
962
988
|
### 진단(`sqlite_doctor`)
|
|
@@ -964,6 +990,7 @@ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른
|
|
|
964
990
|
| 증상 | 원인 | 대처 | 근거 |
|
|
965
991
|
|---|---|---|---|
|
|
966
992
|
| doctor 를 돌린 뒤 `-wal`·`-shm` 이 생기거나 `journal_mode` 가 바뀌었다 | doctor 는 실제로 연결하므로 `init_command` 가 실행되고, WAL DB 는 연결만 해도 사이드카가 생긴다 | 운영 DB 에서 돈다는 것을 알고 실행한다([주의](#sqlite_doctor)) | 재현함 |
|
|
993
|
+
| `sqlite_version` 줄에 `affected by the SQLite WAL-reset bug` 가 붙는다 | 실행 중인 SQLite 가 3.7.0~3.51.2 이고 백포트 판(3.44.6~3.44.x, 3.50.7~3.50.x)이 아니다. 판 번호만 본다 | 아래 [SQLite WAL-reset 버그](#sqlite-wal-reset-버그)를 읽는다. 수준은 `ok` 라 종료 코드는 그대로다 | 코드상 확인(`doctor.py`), 범위는 [SQLite 문서](https://sqlite.org/wal.html#the_wal_reset_bug)(2026-11-12 확인) |
|
|
967
994
|
| `[process]` 의 `nofile_soft` 가 compose 에 준 값과 다르다 | doctor 는 자기 프로세스의 한도를 본다. 다른 셸이나 호스트에서 돌렸거나, 앱이 `setrlimit` 으로 바꿨다 | `docker compose exec app ...` 로 컨테이너 안에서 돌리고, 앱 PID 의 `/proc/<pid>/limits` 와 맞춰 본다([fd 한도 가이드 §2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md#2-현재-한도-확인)) | 재현함(#32) |
|
|
968
995
|
| 읽기 전용 별칭(`mode=ro`)이 `unknown` 이다 | WAL DB 는 읽기 전용 연결도 `-shm` 에 쓰고 `-wal` 을 만들 수 있어 연결하지 않는다 | 같은 파일의 쓰기 별칭이나 `immutable=1` URI 별칭으로 진단한다([주의](#sqlite_doctor)) | 재현함 |
|
|
969
996
|
| DB 파일이 없다는 `error` | 없는 파일에 연결하면 SQLite 가 빈 DB 를 만들어 다음 boot 의 복원을 막으므로 연결하지 않는다 | 경로를 확인하거나 boot 로 먼저 복원한다 | 코드상 확인(`doctor.py`) |
|
|
@@ -971,31 +998,54 @@ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른
|
|
|
971
998
|
| `--restore-test` 의 `remote_txid` 가 `error`(복제본이 비었다) | 복제본에 LTX 가 없다. 0.5.17 은 복제본 경로·prefix 오타도 같은 결과다 | 설정의 복제본 경로를 확인하고 헬스(`remote_txid`)도 본다 | 재현함 |
|
|
972
999
|
| `--restore-test` 산출물이 `--restore-dir` 에 쌓인다 | 실패(또는 `--keep`)한 실행의 작업 디렉터리는 조사용으로 남기고 자동으로 지우지 않는다 | `output` 경로를 확인한 뒤 지운다 | 코드상 확인(`restore_check.py`) |
|
|
973
1000
|
|
|
1001
|
+
#### SQLite WAL-reset 버그
|
|
1002
|
+
|
|
1003
|
+
SQLite 공식 문서([wal.html §11](https://sqlite.org/wal.html#the_wal_reset_bug), 2026-11-12·2026-11-13 확인)는 WAL 모드에서 드물게 DB 를 손상시키는 버그를 적는다.
|
|
1004
|
+
|
|
1005
|
+
- 영향 범위: 3.7.0 ~ 3.51.2. 3.51.3 에서 고쳐졌고(수정은 2026-03-03) 3.44.6·3.50.7 로 백포트됐다.
|
|
1006
|
+
- 조건: WAL 모드 DB 에 연결이 둘 이상(스레드든 프로세스든) 있고, 쓰기나 체크포인트가 같은 순간에 겹칠 때. 이 패키지가 권하는 구성(WAL + 여러 연결 + Litestream 의 체크포인트)은 이 조건에 해당한다.
|
|
1007
|
+
- SQLite 의 평가: 결과는 심각하지만 긴급 사안은 아니고 발생률이 매우 낮다. 업그레이드 외의 우회책은 없다.
|
|
1008
|
+
|
|
1009
|
+
실행 중인 판은 `sqlite_doctor` 의 `sqlite_version` 줄이나 `python -c "import sqlite3; print(sqlite3.sqlite_version)"` 로 확인한다. Python 의 `sqlite3` 모듈이 연결된 SQLite 가 기준이다(`sqlite3` 명령줄 도구의 판과 다를 수 있다).
|
|
1010
|
+
|
|
1011
|
+
- 배포판은 상류 판 번호(`sqlite_version`)를 바꾸지 않고 패치할 수 있다. 그래서 doctor 는 경고(`warn`)가 아니라 메시지로만 알린다. 2026-11-13 확인한 상태(`apt-get changelog`·`apt-cache policy`, [기록](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-13-profile-image.md)):
|
|
1012
|
+
- Ubuntu 22.04(`3.37.2-2ubuntu0.9`)·24.04(`3.45.1-1ubuntu2.10`)는 "Fix WAL-Reset corruption race. (LP: #2171337)"를 `-proposed` 에만 올렸다. `-updates`·`-security` 의 판(`ubuntu0.8`·`ubuntu2.9`)에는 없다. 상류 3.51.3 수정과 같은 패치인지는 미확인이다.
|
|
1013
|
+
- Debian bookworm(`3.40.1-2+deb12u2`)·trixie(`3.46.1-7+deb13u2`)의 changelog 에는 해당 항목이 없다. testing·unstable 은 상류 3.54.1 이다.
|
|
1014
|
+
- 이런 패치 판에서도 판 번호는 영향 범위로 보이므로 메시지가 나온다.
|
|
1015
|
+
- 철회된 3.52.0(2026-03-06)은 수정 check-in([`7168988acb`](https://sqlite.org/src/info/7168988acb), 2026-03-03, trunk)을 조상으로 둔다(sqlite.org 타임라인, 2026-11-13 확인). 그래서 영향 범위로 보지 않는다.
|
|
1016
|
+
- uv 가 받는 Python(python-build-standalone)은 SQLite 를 정적으로 품는다. 3.13·3.14·3.15 모두 3.53.1 로 영향 범위가 아니다(Linux arm64·amd64, 2026-11-13). 배포 프로필의 Dockerfile 은 #117 부터 이 Python 을 쓰고, SQLite 가 3.51.3 미만이면 빌드를 멈춘다([프로필](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/single-server.md#dockerfile)). 공식 `python:*-slim` 이미지(Debian trixie)는 3.46.1, `-slim-bookworm` 은 3.40.1 이다.
|
|
1017
|
+
- Litestream 0.5.17·0.5.18 바이너리는 SQLite 3.53.0(modernc.org/sqlite v1.49.1)을 정적으로 품는다. Litestream 쪽 연결은 영향 범위가 아니다(#117).
|
|
1018
|
+
- 이 패키지 구성에서 실제로 일어나는지는 재현을 시도하지 않았다(미검증).
|
|
1019
|
+
|
|
974
1020
|
### boot
|
|
975
1021
|
|
|
976
1022
|
| 증상 | 원인 | 대처 | 근거 |
|
|
977
1023
|
|---|---|---|---|
|
|
978
1024
|
| 첫 배포에서 exit 2, `no_replica_no_local` | Litestream 은 복제본 경로·prefix 오타와 "복제본 없음"을 똑같이 rc 0·`[]` 로 돌려준다 | 경로를 확인하고 `--init-new` 를 한 번만([생애 첫 배포](#생애-첫-배포---init-new-를-한-번-쓰고-끈다)) | 재현함(Litestream 0.5.17, #3) |
|
|
979
|
-
| exit 2, `remote_error`
|
|
980
|
-
| S3 가 내려가 연결을
|
|
1025
|
+
| exit 2, `remote_error` (평문 HTTP S3 호환 서버). 0.5.17 은 30초 뒤 사유 `timed out`, 0.5.18 은 약 7초 뒤 사유 `exited with 1: … exceeded maximum number of attempts, 8, … Get "https://…` | `endpoint` 에 스킴이 없으면 Litestream 이 HTTPS 로 접속한다. 0.5.17 은 끝없이 기다려 boot 가 `--ltx-timeout`(기본 30초)에서 끊고, 0.5.18 은 재시도를 다 쓰고 스스로 실패한다(Litestream 원문 끝은 `http: server gave HTTP response to HTTPS client` 지만 boot 사유는 300자에서 잘린다). 사유의 URL 이 `https://` 로 시작하면 이 경우다 | `endpoint: http://…` 로 스킴을 붙인다 | 재현함(평범한 HTTP 서버로, 두 판, [#105](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-12-litestream-pitfalls-0.5.18.md#1-endpoint-에-스킴이-없을-때)) |
|
|
1026
|
+
| S3 가 내려가 연결을 거부해 exit 2, `remote_error`. 0.5.17 은 30초 걸리고 사유 `timed out`, 0.5.18 은 약 8초에 사유 `exited with 1: … exceeded maximum number of attempts` | Litestream 0.5.17 의 `ltx` 는 연결 거부에도 곧바로 실패하지 않고 재시도하며 기다린다. 0.5.18 은 S3 재시도를 다 쓰면 스스로 실패한다 | S3 를 복구한 뒤 다시 띄운다. 0.5.17 에서 더 빨리 거부하려면 `--ltx-timeout` 을 줄인다 | 재현함(회귀 랩 L4: toxiproxy 로 끊음 → 0.5.17 30.5초, 0.5.18 7.7초, [#99](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-12-litestream-0.5.18.md)) |
|
|
1027
|
+
| exit 2, `remote_error` 이고 사유에 `unsupported litestream version` | 설치된 Litestream 이 검증한 판이 아니다. boot 는 출력 형식을 재지 않은 판으로 판정하지 않는다. `hint:` 줄에 설치된 판과 검증한 판 목록이 나온다 | 검증한 판을 설치하거나 `--litestream` 으로 그 바이너리를 가리킨다. 새 판을 받으려면 이 패키지의 새 릴리스를 기다린다([지원 범위](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#5-django-쪽-구성)) | 재현함(`version` 에 다른 판을 답하는 대역, #100) |
|
|
1028
|
+
| exit 2, `remote_error` 이고 사유에 `cannot run`(`No such file or directory`·`Permission denied`) 또는 `litestream version exited with` | `--litestream`(기본 `litestream`, `PATH` 에서 찾는다)을 실행할 수 없다. `hint:` 줄에 그 경로가 나온다 | Litestream 을 설치하거나(<https://litestream.io/install/>) `--litestream` 에 실행 가능한 절대 경로를 준다 | 재현함(없는 경로, #100) |
|
|
1029
|
+
| exit 2, `remote_error` 이고 사유에 `cannot parse litestream version output` | `--litestream` 이 Litestream 이 아니거나 개발 빌드라 `litestream version` 이 `X.Y.Z` 가 아니다. `hint:` 줄에 받은 출력이 나온다 | `--litestream` 이 릴리스 바이너리를 가리키는지 확인하고 검증한 판을 설치한다 | 재현함(대역, #100) |
|
|
981
1030
|
| exit 2, `remote_error` 이고 사유에 `database not found in config` | `--db` 가 `litestream.yml` 의 `path` 와 다르다 | 세 곳을 같은 실제 경로로([경로 규칙](#경로-규칙)) | 재현함(fixture `ltx_db_not_in_config`) |
|
|
982
1031
|
| exit 64, 실제 경로 안내 | `--db`·`--meta-path` 의 부모에 심볼릭 링크·`..`·없는 디렉터리 | 안내된 실제 경로를 쓰고, 이미지에서 부모 디렉터리를 만든다([경로 규칙](#경로-규칙)) | 코드상 확인([D-15](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
|
|
983
1032
|
| exit 64, `--meta-path ... overlaps the boot lock`(또는 db·state file·`--config`·restore/quarantine area) | `--meta-path` 가 boot 소유 경로와 같거나 조상·자손이다. 그대로 두면 복원 때 boot 가 잠금·상태 파일을 격리해 잠금 보호가 사라진다 | Litestream 설정의 `meta-path` 와 같은 자기만의 디렉터리를 준다([함정](#함정)) | 재현함(#39, 실제 litestream 으로 두 번째 잠금 획득) |
|
|
984
1033
|
| exit 2, `--meta-path ... is not a directory`·`broken symbolic link` | 메타 경로에 정규 파일·FIFO·끊어진 링크가 있다 | 메타 경로를 확인해 디렉터리로 두거나 경로를 고친다. boot 는 그 파일을 격리하지 않는다 | 코드상 확인(#39 테스트) |
|
|
985
1034
|
| exit 5 | 같은 볼륨에서 boot 나 그것이 exec 한 앱이 이미 돈다(잠금은 exec 된 명령에 상속) | 앞선 컨테이너를 멈춘다. `<db>.boot.lock` 은 지우지 않는다([함정](#boot-cli)) | 코드상 확인 |
|
|
986
1035
|
| 옛 볼륨으로 다시 띄우자 exit 2, `remote_ahead` | 복제본이 이 볼륨보다 새롭다. 보호가 없으면 최신본이 옛 볼륨으로 덮인다 | 복제본이 정본이면 `--on-unknown restore` 로 한 번([사유 코드별 대처](#거부됐을-때-exit-2-사유-코드별-대처)) | 실측(docker D4: 150건이 55건으로 덮임), 재현함(회귀 랩 L2: boot 가 exit 2 로 막고 복제본 무변경) |
|
|
987
|
-
| 복원 직후 컨테이너가 죽은 뒤 exit 2, `no_local_meta` | 복원한 DB 옆에는 Litestream 메타가 없고 `replicate` 가 첫 변경을 기록해야 생긴다 | `--on-unknown restore` 로 한 번([복원 직후 크래시 복구](#복원-직후-크래시-복구)) | 실측(restore 결과에 메타
|
|
988
|
-
| 직접 만든 entrypoint 의 `litestream restore -if-db-not-exists` 가 복원을 건너뛴다 | 0바이트 DB 파일이 있으면 rc 0 으로 건너뛴다 | boot 를 쓴다. `manage.py` 를 boot 앞에서 부르지 않는다([boot CLI](#boot-cli)) | 재현함([DESIGN §4-1](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#4-1-왜-django-관리-명령이-아닌가)) |
|
|
1036
|
+
| 복원 직후 컨테이너가 죽은 뒤 exit 2, `no_local_meta` | 복원한 DB 옆에는 Litestream 메타가 없고 `replicate` 가 첫 변경을 기록해야 생긴다 | `--on-unknown restore` 로 한 번([복원 직후 크래시 복구](#복원-직후-크래시-복구)) | 실측(restore 결과에 메타 없음. 0.5.17·0.5.18, #105), [D-14](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md) |
|
|
1037
|
+
| 직접 만든 entrypoint 의 `litestream restore -if-db-not-exists` 가 복원을 건너뛴다 | 0바이트 DB 파일이 있으면 rc 0 으로 건너뛴다(`database already exists, skipping`) | boot 를 쓴다. `manage.py` 를 boot 앞에서 부르지 않는다([boot CLI](#boot-cli)) | 재현함([DESIGN §4-1](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#4-1-왜-django-관리-명령이-아닌가). 0.5.17·0.5.18, #105) |
|
|
989
1038
|
|
|
990
1039
|
### 복제·헬스
|
|
991
1040
|
|
|
992
1041
|
| 증상 | 원인 | 대처 | 근거 |
|
|
993
1042
|
|---|---|---|---|
|
|
994
|
-
| S3 가 끊겼는데 Litestream 로그·`status`·메트릭이 조용하다 | Litestream 은 업로드 실패를 드러내지
|
|
995
|
-
| S3 가 통째로 끊긴 동안 헬스가 `backlog` 가 아니라 `unknown`(`stale` 또는 `remote_error`) | 헬스의 원격 조회도 같은 S3 로 간다. 조회가
|
|
1043
|
+
| S3 가 끊겼는데 Litestream 로그·`status`·메트릭이 조용하다 | Litestream 은 업로드 실패를 드러내지 않는다. `litestream status` 는 로컬만 보아 두 판 모두 rc 0·`ok` 이고, 메트릭의 `litestream_sync_error_count` 도 0 그대로다(`sync` 는 로컬 단계). 로그는 0.5.18 이 연결 거부에만 ERROR 줄을 남기고 무응답에는 두 판 모두 0줄이다 | 헬스 본문 `status` 로 알람을 건다([복제 헬스](#복제-헬스)) | 실측([docker D3](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/litestream-django-docker.md#d3-관측-조용한-실패-위험)), 재현함(0.5.17·0.5.18 의 `status`·메트릭, [#105](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-12-litestream-pitfalls-0.5.18.md#9-s3-단절-중-litestream-status-와-메트릭)) |
|
|
1044
|
+
| S3 가 통째로 끊긴 동안 헬스가 `backlog` 가 아니라 `unknown`(`stale` 또는 `remote_error`) | 헬스의 원격 조회도 같은 S3 로 간다. 조회가 끝나지 않는 동안(무응답이면 30초 타임아웃까지, 연결 거부는 0.5.17 30초·0.5.18 약 7초) 결과가 `REFRESH × 3` 보다 오래되어 `stale`, 끝나면 `remote_error` 다. 업로드만 끊기고 조회가 되면 `backlog`(`local_ahead`)다 | 둘 다 알람 대상으로 둔다(`caught_up` 이 아니면 경보) | 재현함(회귀 랩 L8a·L8b) |
|
|
1045
|
+
| 헬스가 `backlog`(`compaction_stalled`)이고 Litestream 로그에 `compaction failed` 가 30초마다 나온다 | 로컬 L0 하나가 깨지면 L1 컴팩션이 계속 실패한다. L0 업로드는 계속되어 로컬·원격 TXID 는 같다(예전 헬스는 `caught_up` 이었다, #115 에서 고침). 원격 L0 가 쓰기 빈도에 비례해 쌓인다. **최신** 로컬 L0 가 깨진 경우는 판마다 다르다: 0.5.17 은 sync 도 멈춰(`sync error … last page match`) `db_not_replicated`, 0.5.18 은 마지막 페이지를 메모리에서 비교해 sync 가 계속되므로 `compaction_stalled` 다 | 로컬 메타(`.<db>-litestream/`)의 깨진 L0 를 확인한다. 앱을 멈춘 뒤 boot `--on-unknown restore` 로 복제본에서 다시 시작하는 것이 안전하다(복원은 L0 사슬로 아직 된다 — #106). L1 주기를 바꿨다면 `COMPACTION_GRACE` 를 맞춘다 | 재현함(#106 랩 0.5.17·0.5.18, #115 로컬 실제 바이너리 두 판·랩 L9a 두 판, [기록](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-13-health-pipeline-stall.md)). 0.5.18 의 차이는 `db.go` `lastLTXPage`(코드상 확인). 이전 기록: (#106, 0.5.17·0.5.18, 로컬 L0 손상 주입 8분에 L0 21 → 440~449개, [기록](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-11-12-wal-autocheckpoint.md#q4-컴팩션이-멈추면-원격-l0-가-쌓이고-그때-헬스는-무엇을-말하는가)) |
|
|
996
1046
|
| 배포 직후 헬스가 `unknown`(`not_checked`) | 갱신 스레드는 첫 헬스 요청 때 시작한다 | `REFRESH`(기본 15초) 뒤 다시 본다 | 코드상 확인(`health.py`) |
|
|
997
1047
|
| 헬스가 `backlog` 인데 HTTP 200 이다 | 설계상 항상 200 이다. 복제 지연으로 로드밸런서가 앱을 빼면 안 된다 | 본문 `status` 로 알람. HTTP 코드만 보는 모니터는 `?strict=1`(로드밸런서에는 쓰지 않는다) | 코드상 확인(`health.py`) |
|
|
998
|
-
| 헬스가 `unknown`(`no_wal_evidence`) | `-wal` 이 없거나 비어 WAL 위치로 판정할 수 없다. Litestream 이 돌면 `-wal` 이
|
|
1048
|
+
| 헬스가 `unknown`(`no_wal_evidence`) | `-wal` 이 없거나 비어 WAL 위치로 판정할 수 없다. Litestream 이 돌면 `-wal` 이 남는다. 요청마다 연결을 닫는 앱에서 Litestream 이 없으면 `-wal` 이 지워졌다 생겨 이 값이 잠깐씩 나오고, 최신 L0 가 그대로이므로 `BACKLOG_GRACE` 뒤 `backlog`(`db_not_replicated`)가 된다(#115 전에는 `caught_up` 과 이 값을 오갔다) | `litestream replicate` 가 도는지 본다 | 코드상 확인(`health.py`), 재현함(#106 Q3 dead, #115 랩 L9b) |
|
|
999
1049
|
| 헬스가 `unknown`(`remote_ahead`) | 복제본이 로컬보다 앞섰다. 다른 머신이 같은 복제본에 쓰는 중일 수 있다 | 쓰는 머신이 1대인지 확인한다. 두 머신이 쓰면 나중 쪽이 경고 없이 이긴다 | 실측(scenarios S2b) |
|
|
1000
1050
|
| 헬스가 `unknown`(`db_missing`·`db_empty`) | DB 본 파일이 없거나 0바이트다. `-wal`·메타가 남아 예전에는 `caught_up` 으로 보였다 | [DB 본 파일 상태](#db-본-파일-상태)의 대처를 따른다 | 재현함(#40, 실제 Litestream 0.5.17) |
|
|
1001
1051
|
| 헬스가 계속 `unknown`(`unknown_at_boot`) | boot 가 `--on-unknown keep-local` 로 진행했다 | 원인을 푼 뒤 옵션 없이 다시 부팅한다 | 코드상 확인(`health.py`) |
|
|
@@ -1020,10 +1070,10 @@ uv venv && uv pip install -e . --group dev
|
|
|
1020
1070
|
uv run --no-sync pytest
|
|
1021
1071
|
|
|
1022
1072
|
scripts/ci-local.sh # GitHub Actions CI 를 act 로 로컬 실행 (lint + 전체 매트릭스)
|
|
1023
|
-
scripts/ci-local.sh test 3.
|
|
1073
|
+
scripts/ci-local.sh test 3.15 6.1 # 매트릭스 한 칸만 (python, django — 3.15 × 5.2 칸은 없다)
|
|
1024
1074
|
scripts/ci-local.sh litestream # 실제 litestream 바이너리 + channels-lite[aio]==0.4.0 으로 통합 테스트 (둘 중 하나가 없어 skip 되면 실패)
|
|
1025
1075
|
|
|
1026
|
-
scripts/lab.sh run all # 회귀 랩 L1–
|
|
1076
|
+
scripts/lab.sh run all # 회귀 랩 L1–L9·프로필 종단 검증 (Docker, 약 19분, 끝나면 down -v)
|
|
1027
1077
|
scripts/lab.sh bench # PRAGMA 벤치 (lab/README.md)
|
|
1028
1078
|
scripts/lab.sh dbconfig # Django 기본 대 권장 설정 비교 (약 25분, #83)
|
|
1029
1079
|
|