django-sqlite-ops 0.1.0a1__tar.gz → 0.1.0a2__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.
Files changed (185) hide show
  1. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/CHANGELOG.md +13 -2
  2. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/PKG-INFO +60 -11
  3. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/README.md +58 -9
  4. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/DESIGN.md +6 -5
  5. django_sqlite_ops-0.1.0a2/docs/assets/charts.py +287 -0
  6. django_sqlite_ops-0.1.0a2/docs/assets/conn-max-age.svg +2905 -0
  7. django_sqlite_ops-0.1.0a2/docs/assets/dbconfig.svg +4462 -0
  8. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/profiles/single-server-multiproc.md +7 -6
  9. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/profiles/single-server.md +4 -4
  10. django_sqlite_ops-0.1.0a2/docs/research/bench-2026-10-26-default-vs-recommended.md +185 -0
  11. django_sqlite_ops-0.1.0a2/docs/research/data/conn-max-age-2026-10-08.csv +81 -0
  12. django_sqlite_ops-0.1.0a2/docs/research/data/dbconfig-2026-10-26.csv +61 -0
  13. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/README.md +26 -16
  14. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/_checks.py +90 -49
  15. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/lab_tools/chanlab_run.py +16 -19
  16. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/lab_tools/loadgen.py +6 -2
  17. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/chanlab.py +4 -3
  18. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/views.py +21 -1
  19. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/proj/settings_tail.py +12 -0
  20. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/proj/urls_tail.py +1 -0
  21. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/build_context.py +6 -4
  22. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/conftest.py +3 -0
  23. django_sqlite_ops-0.1.0a2/lab/dbconfig_report.py +176 -0
  24. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/lab-compose.yaml +13 -0
  25. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/test_bench.py +14 -3
  26. django_sqlite_ops-0.1.0a2/lab/test_dbconfig.py +210 -0
  27. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/test_scenarios.py +32 -139
  28. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/pyproject.toml +2 -2
  29. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/scripts/ci-local.sh +4 -1
  30. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/scripts/lab.sh +10 -3
  31. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/scripts/release_check.py +33 -3
  32. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_lab.py +192 -85
  33. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_profiles.py +1 -1
  34. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_release_check.py +48 -3
  35. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/.gitignore +0 -0
  36. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/LICENSE +0 -0
  37. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/__init__.py +0 -0
  38. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/apps.py +0 -0
  39. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/boot/__init__.py +0 -0
  40. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/boot/__main__.py +0 -0
  41. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/boot/cli.py +0 -0
  42. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/boot/decide.py +0 -0
  43. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/boot/litestream.py +0 -0
  44. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/checks.py +0 -0
  45. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/compat/__init__.py +0 -0
  46. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/compat/_channels_lite_aio.py +0 -0
  47. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/compat/channels_lite.py +0 -0
  48. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/database.py +0 -0
  49. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/doctor.py +0 -0
  50. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/health.py +0 -0
  51. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/management/__init__.py +0 -0
  52. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/management/commands/__init__.py +0 -0
  53. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/management/commands/sqlite_doctor.py +0 -0
  54. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/restore_check.py +0 -0
  55. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/django_sqlite_ops/wal.py +0 -0
  56. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/DECISIONS.md +0 -0
  57. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/profiles/fd-limits.md +0 -0
  58. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/reference/Dockerfile +0 -0
  59. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/reference/README.md +0 -0
  60. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/reference/compose.yaml +0 -0
  61. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/reference/entrypoint.sh +0 -0
  62. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/reference/guard_spike.py +0 -0
  63. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/reference/vfs_workaround_spike.py +0 -0
  64. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/bench-2026-10-08.md +0 -0
  65. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/cache-size-2026-10-12.md +0 -0
  66. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/cross-review-gpt-6-astra.md +0 -0
  67. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/fd-soak-2026-10-09.md +0 -0
  68. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/handoff-marco-layer-wording.md +0 -0
  69. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/handoff-marco-nats-flush-cancel.md +0 -0
  70. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/lab-2026-10-08.md +0 -0
  71. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/lab-2026-10-25-l4-two-way.md +0 -0
  72. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/lab-2026-10-25-long-outage.md +0 -0
  73. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/lab-2026-10-26-multiproc-nats.md +0 -0
  74. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/litestream-django-docker.md +0 -0
  75. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/litestream-django-scenarios.md +0 -0
  76. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/sqlite-django-package-review-v0.md +0 -0
  77. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/sqlite-django-package-review-v1.md +0 -0
  78. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/docs/research/turso-django.md +0 -0
  79. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/_benchstat.py +0 -0
  80. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/_cachestat.py +0 -0
  81. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/_lab.py +0 -0
  82. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/_soakstat.py +0 -0
  83. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/lab_hooks/sitecustomize.py +0 -0
  84. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/lab_tools/inspect_data.py +0 -0
  85. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/lab_tools/procs.py +0 -0
  86. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/lab_tools/replica.py +0 -0
  87. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/lab_tools/s3_objects.py +0 -0
  88. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/manage.py +0 -0
  89. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/__init__.py +0 -0
  90. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/apps.py +0 -0
  91. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/management/__init__.py +0 -0
  92. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/management/commands/__init__.py +0 -0
  93. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/management/commands/lab_seed.py +0 -0
  94. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/metrics.py +0 -0
  95. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/migrations/0001_initial.py +0 -0
  96. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/migrations/__init__.py +0 -0
  97. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/models.py +0 -0
  98. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/notes/timing.py +0 -0
  99. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/proj/__init__.py +0 -0
  100. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/app/proj/asgi.py +0 -0
  101. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/cache_mech.py +0 -0
  102. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/cache_probe.py +0 -0
  103. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/cache_report.py +0 -0
  104. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/multiproc-override.yaml +0 -0
  105. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/nats.Dockerfile +0 -0
  106. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/profile-override.yaml +0 -0
  107. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/soak_report.py +0 -0
  108. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/lab/test_soak.py +0 -0
  109. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/_channels_lite.py +0 -0
  110. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/_litestream.py +0 -0
  111. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/_nodjango.py +0 -0
  112. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/conftest.py +0 -0
  113. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/README.md +0 -0
  114. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_bad_yaml/rc +0 -0
  115. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_bad_yaml/stderr +0 -0
  116. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_bad_yaml/stdout +0 -0
  117. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_config_missing/rc +0 -0
  118. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_config_missing/stderr +0 -0
  119. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_config_missing/stdout +0 -0
  120. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_empty/rc +0 -0
  121. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_empty/stderr +0 -0
  122. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_empty/stdout +0 -0
  123. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_json/rc +0 -0
  124. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_json/stderr +0 -0
  125. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/databases_json/stdout +0 -0
  126. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/local_meta_after_replicate/meta/ltx/0/0000000000000019-0000000000000019.ltx +0 -0
  127. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_all_levels/rc +0 -0
  128. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_all_levels/stderr +0 -0
  129. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_all_levels/stdout +0 -0
  130. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_all_levels_text/rc +0 -0
  131. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_all_levels_text/stderr +0 -0
  132. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_all_levels_text/stdout +0 -0
  133. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_bad_yaml/rc +0 -0
  134. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_bad_yaml/stderr +0 -0
  135. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_bad_yaml/stdout +0 -0
  136. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_config_missing/rc +0 -0
  137. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_config_missing/stderr +0 -0
  138. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_config_missing/stdout +0 -0
  139. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_db_not_in_config/rc +0 -0
  140. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_db_not_in_config/stderr +0 -0
  141. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_db_not_in_config/stdout +0 -0
  142. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_l0_default/rc +0 -0
  143. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_l0_default/stderr +0 -0
  144. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_l0_default/stdout +0 -0
  145. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_no_l0_all_levels/rc +0 -0
  146. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_no_l0_all_levels/stderr +0 -0
  147. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_no_l0_all_levels/stdout +0 -0
  148. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_no_l0_default/rc +0 -0
  149. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_no_l0_default/stderr +0 -0
  150. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_no_l0_default/stdout +0 -0
  151. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_permission_denied/rc +0 -0
  152. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_permission_denied/stderr +0 -0
  153. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_permission_denied/stdout +0 -0
  154. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_replica_empty_dir/rc +0 -0
  155. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_replica_empty_dir/stderr +0 -0
  156. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_replica_empty_dir/stdout +0 -0
  157. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_replica_missing/rc +0 -0
  158. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_replica_missing/stderr +0 -0
  159. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_replica_missing/stdout +0 -0
  160. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/ltx_sample/0000000000000001-0000000000000001.ltx +0 -0
  161. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/make_fixtures.sh +0 -0
  162. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_no_backups/rc +0 -0
  163. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_no_backups/stderr +0 -0
  164. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_no_backups/stdout +0 -0
  165. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_ok_json/rc +0 -0
  166. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_ok_json/stderr +0 -0
  167. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_ok_json/stdout +0 -0
  168. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_output_exists/rc +0 -0
  169. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_output_exists/stderr +0 -0
  170. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/restore_output_exists/stdout +0 -0
  171. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/version/rc +0 -0
  172. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/version/stderr +0 -0
  173. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/fixtures/litestream-0.5.17/version/stdout +0 -0
  174. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_boot_cli.py +0 -0
  175. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_boot_decide.py +0 -0
  176. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_boot_litestream.py +0 -0
  177. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_checks.py +0 -0
  178. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_compat_channels_lite.py +0 -0
  179. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_database.py +0 -0
  180. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_doc_links.py +0 -0
  181. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_doctor.py +0 -0
  182. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_environment.py +0 -0
  183. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_health.py +0 -0
  184. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_readme.py +0 -0
  185. {django_sqlite_ops-0.1.0a1 → django_sqlite_ops-0.1.0a2}/tests/test_restore_check.py +0 -0
@@ -2,10 +2,20 @@
2
2
 
3
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
4
 
5
- > ⚠️ **실험적 프로젝트다.** 1.0 전에는 API·동작·지원 범위가 예고 없이 바뀔 수 있다. 사전 릴리스(`a`·`b`·`rc`)는 버전을 명시해 설치한다(`pip install "django-sqlite-ops==0.1.0a1"`). `--pre` 는 모든 의존성의 사전 릴리스까지 허용하므로 쓰지 않는다. 운영 환경에서는 버전을 고정한다.
5
+ > ⚠️ **실험적 프로젝트다.** 1.0 전에는 API·동작·지원 범위가 예고 없이 바뀔 수 있다. 사전 릴리스(`a`·`b`·`rc`)는 버전을 명시해 설치한다(`pip install "django-sqlite-ops==0.1.0a2"`). `--pre` 는 모든 의존성의 사전 릴리스까지 허용하므로 쓰지 않는다. 운영 환경에서는 버전을 고정한다.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.0a2] - 2026-10-27
10
+
11
+ 의존성 하한과 문서를 고친 사전 릴리스다. 패키지 코드(`django_sqlite_ops/`)는 0.1.0a1 과 같다. 저장소 안의 회귀 랩·CI 정비(#88·#90·#94)는 sdist 에는 실리지만 설치되는 코드(wheel)에는 영향이 없으므로 여기 적지 않는다.
12
+
13
+ ### Changed
14
+
15
+ - **`[nats]` extra 가 `channels-nats>=0.10.1` 을 요구한다.** 0.10.0 이하에서는 구독 확인이 취소되거나 시간 초과되면 그 워커의 채널 수신이 예외 없이 멈춘다. 0.10.1 은 같은 API 의 패치 릴리스다 (#91·#88)
16
+ - **README(PyPI 설명)를 고쳤다.** '왜 쓰는가' 절에 Django 기본 설정 대 권장 설정, `CONN_MAX_AGE` 의 실측 차트와 boot 의 실패 경계 L1–L8 표를 넣었다. 나빠진 지표와 재현되지 않은 이득도 함께 적는다 (#83). 설치 절에 channels-nats 하한 안내를 넣었다 (#91). 문제 해결 '채널 레이어'의 channels-nats 결함 안내를 0.10.1 기준으로 고쳤다 (#88)
17
+ - 배포 프로필 `single-server` 의 `requirements.txt` 예가 이 패키지를 PyPI 에서 받는다 (#84)
18
+
9
19
  ## [0.1.0a1] - 2026-10-26
10
20
 
11
21
  첫 실험적 사전 릴리스다([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)만 지원한다.
@@ -26,5 +36,6 @@
26
36
 
27
37
  - 미검증 항목은 [DESIGN §12](docs/DESIGN.md#12-미검증확인-필요) 에 있다. 실제 클라우드 S3·R2·Tigris, Windows 의 boot CLI, ASGI 에서의 VFS 별칭 동작은 검증하지 않았다.
28
38
 
29
- [Unreleased]: https://github.com/itda-work/django-sqlite-ops/compare/v0.1.0a1...HEAD
39
+ [Unreleased]: https://github.com/itda-work/django-sqlite-ops/compare/v0.1.0a2...HEAD
40
+ [0.1.0a2]: https://github.com/itda-work/django-sqlite-ops/compare/v0.1.0a1...v0.1.0a2
30
41
  [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.0a1
3
+ Version: 0.1.0a2
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
@@ -26,7 +26,7 @@ Requires-Dist: django>=5.2
26
26
  Provides-Extra: channels-lite
27
27
  Requires-Dist: channels-lite; extra == 'channels-lite'
28
28
  Provides-Extra: nats
29
- Requires-Dist: channels-nats; extra == 'nats'
29
+ Requires-Dist: channels-nats>=0.10.1; extra == 'nats'
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # django-sqlite-ops
@@ -35,9 +35,54 @@ Description-Content-Type: text/markdown
35
35
 
36
36
  Django 에서 SQLite 를 운영 DB 로 안전하게 쓰게 하는 **운영 도구**다. 권장 설정, 부팅 시 복원 판정, 진단, 복제 헬스, 배포 프로필을 맡는다. DB 백엔드를 바꾸거나 감싸지 않고, 표준 `DATABASES` 설정만 다룬다.
37
37
 
38
+ ## 왜 쓰는가
39
+
40
+ 이 패키지는 실측으로 확인한 권장 설정을 한 줄로 넣고, 운영에서 조용히 망가지는 설정과 상태를 막는다. 아래는 모두 회귀 랩(Docker, colima arm64)에서 잰 값이다. 좋아진 것과 나빠진 것을 함께 적는다. 이득이 재현되지 않은 것(PRAGMA `mmap_size`·`cache_size`·`journal_size_limit`)은 기본값에 넣지 않았다([#26](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md), [#27](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/cache-size-2026-10-12.md)).
41
+
42
+ **1. 읽고-고쳐-쓰기 트랜잭션에서 `database is locked` 가 사라진다** ([측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-26-default-vs-recommended.md), 2026-10-26)
43
+
44
+ ![Django 기본 설정 대 권장 설정의 처리량·p99·locked 오류](https://raw.githubusercontent.com/itda-work/django-sqlite-ops/main/docs/assets/dbconfig.svg)
45
+
46
+ - 측정 조건: Django 기본 설정(rollback journal, DEFERRED)과 `sqlite_database()`(WAL, `IMMEDIATE`, `synchronous=NORMAL`)를 같은 앱·같은 부하(동시 16, uvicorn 워커 1·4)로 5회씩 번갈아 돌렸다. Litestream 은 붙이지 않았다. 아래 범위는 반복별 짝 차이의 최소–최대다.
47
+ - `transaction.atomic()` 안에서 읽고 고쳐 쓰는 요청은 Django 기본에서 **42.9–47.9% 가 `database is locked`(HTTP 500)** 로 실패했다. 권장 설정은 10번의 실행 모두에서 0건이었다.
48
+ - 단독 INSERT(autocommit)만 있는 부하는 **두 설정 모두 locked 0** 이었다. locked 는 읽고-고쳐-쓰기 트랜잭션에서만 났다.
49
+ - 50행 INSERT 가 70% 인 부하에서 권장 설정의 처리량과 p99:
50
+ - 워커 1: 처리량 +64.2–133.0%, p99 −47.7 – −61.6%.
51
+ - 워커 4: 처리량 +115.8–884.6%, p99 −77.3 – −92.9%.
52
+ - 읽기 위주 부하(읽기 70%·정렬 10%·1행 INSERT 20%)에서 권장 설정의 p99 는 워커 1 에서 −56.6 – −74.6%, 워커 4 에서 −82.6 – −86.3% 였다. 처리량은 워커 4 에서 +61.0–90.8% 였다.
53
+ - 다 좋아지지는 않았다(모두 5회 반복에서 재현된 것, 또는 재현되지 않은 것).
54
+ - 읽기 위주 부하의 **p50 은 권장 설정이 높았다**: 워커 1 +19.7–64.7%, 워커 4 +48.2–62.3%(재현).
55
+ - 워커 1 의 읽기 위주 부하에서는 처리량 차이가 재현되지 않았다(−12.5 – +15.9%).
56
+ - 읽고-고쳐-쓰기 단계(읽기 50% + 읽고-고쳐-쓰기 50%)의 p99 는 워커 1 에서 **권장 설정이 높았다**(+36.3–143.6%, 재현). 워커 4 는 재현 안 됨(−33.8 – +3.1%).
57
+ - 요청 단위로는 두 워커 모두 **권장 설정의 읽고-고쳐-쓰기 요청이 더 오래 걸렸다**(성공한 요청의 p99: 워커 1 65.6–106.6 → 148.9–227.5 ms, 워커 4 137.6–212.8 → 231.9–336.3 ms). 기본 설정에서 실패했을 요청이 실패 대신 쓰기 잠금을 기다린 것으로 보인다(추정). 권장 설정이 그 단계를 더 큰 DB 에서 시작한 영향도 섞여 있다([한계](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-26-default-vs-recommended.md#한계)).
58
+
59
+ **2. ASGI 에서 `CONN_MAX_AGE=None` 을 막는다** ([#26](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md#결과-연결-재사용), 2026-10-08 · [#33](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/fd-soak-2026-10-09.md), 2026-10-09)
60
+
61
+ ![CONN_MAX_AGE 0 대 None 의 열린 DB fd 와 500 오류](https://raw.githubusercontent.com/itda-work/django-sqlite-ops/main/docs/assets/conn-max-age.svg)
62
+
63
+ - uvicorn 의 동기 뷰에서 `CONN_MAX_AGE=None` 은 연결을 재사용하지 않는다(DB 요청마다 새 연결 약 1개). 대신 닫지 않고 쌓는다.
64
+ - 열린 DB 파일 fd 가 기본 한도 1,024 바로 아래(810–982)까지 올라갔다. 그때 `unable to open database file` 500 이 났다.
65
+ - 한도를 올리면 이 부하(동시 16, 30분)에서는 오류가 없었다. 그래도 `CONN_MAX_AGE=0` 보다 fd 를 16.75배, RSS 를 5.62배 많이 썼고 연결 재사용 이득은 없었다.
66
+ - [시스템 체크](#시스템-체크) `sqlite_ops.W005` 가 이 설정을 알린다.
67
+
68
+ **3. 위험한 부팅을 거부하고, 안전할 때만 복원한다** ([회귀 랩](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md), 2026-10-08 · [L8c·L8d](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-25-long-outage.md), 2026-10-25)
69
+
70
+ Litestream 만 쓰면 옛 볼륨으로 재부팅했을 때 최신 복제본이 덮인다(150건 → 55건, [재현](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/litestream-django-docker.md#d4-부팅-가드-옛-볼륨-재부팅이-최신본을-덮는-문제-막기)). 아래는 [boot CLI](#boot-cli) 가 각 경계에서 한 일이다(모두 Docker 로 재현, arm64·amd64 통과).
71
+
72
+ | 상황 | boot 의 결과 |
73
+ |---|---|
74
+ | L1 볼륨 없는 새 컨테이너 | 복제본에서 복원, 행 수 일치 |
75
+ | L2 옛 볼륨으로 재부팅(복제본이 더 앞섬) | exit 2 `remote_ahead`, 복제본 손상 0 |
76
+ | L3 로컬 메타만 사라짐 | exit 2 `no_local_meta`, DB 무변경 |
77
+ | L4 부팅 중 S3 끊김(거부·무응답) | 타임아웃 뒤 exit 2 `remote_error` |
78
+ | L5 복제되지 않은 로컬 커밋 | 그대로 진행, 커밋 보존 |
79
+ | L6 복원 도중 kill | 반쯤 옮겨진 상태 없음, 재실행으로 완료 |
80
+ | L7 복제본 객체 손상 | exit 4, 로컬 파일 무변경 |
81
+ | L8 S3 끊김 중 헬스 | 업로드만 끊기면 `backlog`, 조회까지 끊기면 `unknown`. 복구 뒤 `caught_up` |
82
+
38
83
  ## 현재 상태
39
84
 
40
- PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md), 2026-10-26 결정). 첫 버전은 `0.1.0a1` 이다. 사전 릴리스는 버전을 명시해 설치한다(아래 [설치](#설치)). 바뀐 점은 [CHANGELOG](https://github.com/itda-work/django-sqlite-ops/blob/main/CHANGELOG.md) 에 적는다. 외부 홍보는 하지 않는다. 패키지 이름 `django-sqlite-ops` 는 확정이다(D-28 이 D-6 을 갱신).
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.0a2` 다. 사전 릴리스는 버전을 명시해 설치한다(아래 [설치](#설치)). 바뀐 점은 [CHANGELOG](https://github.com/itda-work/django-sqlite-ops/blob/main/CHANGELOG.md) 에 적는다. 외부 홍보는 하지 않는다. 패키지 이름 `django-sqlite-ops` 는 확정이다(D-28 이 D-6 을 갱신).
41
86
 
42
87
  | 기능 | 상태 | 이슈·근거 |
43
88
  |---|---|---|
@@ -59,16 +104,16 @@ PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](https://github.co
59
104
  PyPI 에서 설치한다. 사전 릴리스는 버전을 명시해 설치한다. 실험적 프로젝트이므로 그 버전으로 고정해 둔다.
60
105
 
61
106
  ```bash
62
- pip install "django-sqlite-ops==0.1.0a1"
107
+ pip install "django-sqlite-ops==0.1.0a2"
63
108
  # 또는
64
- uv add "django-sqlite-ops==0.1.0a1"
109
+ uv add "django-sqlite-ops==0.1.0a2"
65
110
  ```
66
111
 
67
- 필수 의존성은 Django 하나다. 채널 레이어는 extra 로 고른다: `[nats]`(channels-nats), `[channels-lite]`(channels-lite 만. aio 레이어에 필요한 의존성은 [channels-lite 패치 '함정'](#channels-lite-패치)).
112
+ 필수 의존성은 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` 처럼 그 패키지를 올린다.
68
113
 
69
114
  ```bash
70
- pip install "django-sqlite-ops[nats]==0.1.0a1"
71
- uv add "django-sqlite-ops[channels-lite]==0.1.0a1"
115
+ pip install "django-sqlite-ops[nats]==0.1.0a2"
116
+ uv add "django-sqlite-ops[channels-lite]==0.1.0a2"
72
117
  ```
73
118
 
74
119
  `pip install --pre` 나 `uv add --prerelease=allow` 는 쓰지 않는다. 이 패키지뿐 아니라 Django 를 비롯한 **모든 의존성**의 사전 릴리스까지 허용한다(`uv add --prerelease=allow` 는 `uv.lock` 에 `prerelease-mode = "allow"` 로 남아 이후의 해석에도 적용된다). 버전을 지정자에 명시하면 그 사전 릴리스만 받아들여진다. 정식 릴리스가 나온 뒤에는 명시하지 않으면 사전 릴리스를 고르지 않는다.
@@ -897,7 +942,7 @@ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른
897
942
  - 한 번만 쓰는 boot 옵션(`--init-new`·`--adopt-existing`·`--on-unknown restore`)은 이미지에 굳히지 않고 `BOOT_FLAGS` 환경 변수로 넘긴 뒤 비운다.
898
943
  - 열린 파일 한도(`nofile`)의 확인·설정(Docker `--ulimit`, compose `ulimits`, systemd `LimitNOFILE=`)·계산법은 두 프로필 공통 문서 [docs/profiles/fd-limits.md](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md) 에 있다. 프로필 compose 예에는 `ulimits` 를 넣지 않았다(이유는 그 문서 §6).
899
944
  - `settings.py`·`urls.py` 조각은 테스트가 실행하고 `check --deploy` 를 통과하는지 본다. `litestream.yml` 은 실제 litestream 0.5.17 이 읽는지, `entrypoint.sh` 는 문법을 본다. Dockerfile·compose 는 회귀 랩이 문서 블록을 그대로 꺼내 빌드하고 띄워 종단 검증했다(첫 배포, `check --deploy`, `sqlite_doctor`, `docker stop` 시 exit 0·복제 400/400, 빈 볼륨 소유자 복사, 워커 2개 — [결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md)). multiproc 은 channels-nats(`[nats]` extra)를 넣어 워커 사이 전달도 랩에서 확인했다(2026-10-26, 아래). 2026-10-08 의 multiproc 검증은 랩이 nats 컨테이너를 띄우지 않은 채 돈 것이었다.
900
- - **multiproc 채널 전달(랩 P2, 2026-10-26, channels-nats 0.10.0, uvicorn 워커 2)**: 그룹·프로세스 전용 채널은 워커마다 200/200(워커 → 워커 포함), 일반 채널 큐 그룹은 합 200·중복 0. 수신 루프를 남긴 채 `docker stop` 이 exit 0·0.74초로 끝났다. 손실은 계약대로 두 곳에서만 났다: 일반 채널을 `wait_for` 로 폴링할 때 두 폴링 사이(10ms 폴링에서 16/200), 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘칠 때(간격 없는 버스트 200건). 보내는 쪽에는 어느 쪽도 예외가 없다. 아래 '채널 레이어' 함정 두 줄을 본다([결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-26-multiproc-nats.md)).
945
+ - **multiproc 채널 전달(랩 P2, 2026-10-26 channels-nats 0.10.0, 2026-10-27 0.10.1 로 다시 확인, uvicorn 워커 2)**: 그룹·프로세스 전용 채널은 워커마다 200/200(워커 → 워커 포함), 일반 채널 큐 그룹은 합 200·중복 0. 수신 루프를 남긴 채 `docker stop` 이 exit 0·0.74초로 끝났다. 손실은 계약대로 두 곳에서만 났다: 일반 채널을 `wait_for` 로 폴링할 때 두 폴링 사이(10ms 폴링에서 16/200), 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘칠 때(간격 없는 버스트 200건). 보내는 쪽에는 어느 쪽도 예외가 없다. 0.10.1 에서는 1ms·10ms 폴링 뒤에도 같은 연결이 그룹 메시지를 200/200 받는다(#88, 2026-10-27). 아래 '채널 레이어' 함정을 본다([결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-26-multiproc-nats.md)).
901
946
 
902
947
  ## 문제 해결
903
948
 
@@ -963,7 +1008,7 @@ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른
963
1008
  | channels-lite aio 레이어에서 한 메시지가 두 수신자에게 배달된다 | 선점 성공을 누적 `total_changes` 로 판정한다 | `SQLITE_OPS["PATCH_CHANNELS_LITE_AIO"] = True`([channels-lite 패치](#channels-lite-패치)) | 재현함(#8) |
964
1009
  | 새 채널 DB 에 여러 프로세스가 처음 붙을 때 `database is locked` | 여러 연결이 동시에 처음 `journal_mode=WAL` 로 바꾸면 일부가 실패한다(원인 미확정) | `migrate` 직후 채널 DB 를 미리 WAL 로 바꿔 둔다 | 재현함(#8) |
965
1010
  | 버스트로 보낸 메시지 일부가 사라지는데 경고는 "1 message(s) dropped so far" 한 줄뿐이다 | channels-nats 는 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘치면 버린다(`ChannelFull` 없음). 경고는 60초에 한 번으로 묶이고 합계는 자리가 난 뒤 다음 메시지가 올 때 "has room again after dropping N" 으로 나온다 — 첫 줄만 보면 과소평가한다(실측 1줄 / 실제 1–16건) | 합계 줄을 본다. 한 번에 몰아 보내는 양이 `capacity` 를 넘으면 `capacity` 를 올리거나 나눠 보낸다. channels-nats D2 실측의 196/200 도 같은 모양이라 이것으로 본다(추론) | 재현함(#66) |
966
- | 한 워커가 채널 메시지를 하나도 받지 못한다. 로그에 `nats: encountered error … InvalidStateError` | 구독 확인(`new_channel`·`group_add`·일반 채널 첫 `receive`)이 취소되거나 시간 초과되면 nats-py 2.16.0 의 읽기 루프가 끝나 그 연결이 그룹 메시지까지 받지 못한다. 그 뒤의 `new_channel`·`group_add` 도 예외 없이 성공한다. 일반 채널을 1ms `wait_for` 로 폴링하는 것은 그 한 예다(알려진 결함, 회복 시간 미검증) | 일반 채널을 `wait_for` 로 폴링하지 않는다. 10ms 폴링에서도 재현됐다(랩 RTT 약 0.25ms 에서 P2 6회 중 1회, 2026-10-26 확인). 안전한 폴링 간격은 없다. 브로커가 10초 넘게 멈췄다 살아난 뒤에도 같은 일이 날 수 있다(nats-py flush 타임아웃·레이어 `publish_deadline`). 로그에 위 줄이 있는 워커는 재시작한다. 상류 처리는 [이관 문서](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/handoff-marco-nats-flush-cancel.md) | 1ms·10ms 폴링: 재현함(#66). 타임아웃·deadline 경로: 코드상 확인 |
1011
+ | 한 워커가 채널 메시지를 하나도 받지 못한다. 로그에 `nats: encountered error … InvalidStateError` | channels-nats 0.10.0 이하: 구독 확인(`new_channel`·`group_add`·일반 채널 첫 `receive`)이 취소되거나 시간 초과되면 nats-py 2.16.0 의 읽기 루프가 끝나 그 연결이 그룹 메시지까지 받지 못한다. 그 뒤의 `new_channel`·`group_add` 도 예외 없이 성공한다. 일반 채널을 `wait_for` 로 폴링하는 것(1ms 매번, 10ms 도 6회 중 1회), 브로커가 10초 넘게 멈췄다 살아나는 것(nats-py flush 타임아웃·레이어 `publish_deadline`)이 그 예다 | **channels-nats 0.10.1 이상으로 올린다.** 0.1.0a2 부터 `[nats]` 가 `>=0.10.1` 을 요구한다. 0.1.0a1 의 `[nats]` 는 하한이 없어 0.10.0 을 그대로 둘 수 있다. 0.10.0 이하로 고정했거나 lock 에 잠겼으면 그 고정을 풀어 올린다. 0.10.1 은 nats-py 2.16.0 이하일 때 상류 수정(nats-py PR #1031, 병합·미릴리스)과 같은 패치를 넣는다. 올리기 전에는 로그에 위 줄이 있는 워커를 재시작한다. 일반 채널 폴링은 0.10.1 에서도 두 폴링 사이의 메시지를 잃는다(계약) | 0.10.0 의 1ms·10ms 폴링: 재현함(#66). 0.10.1 에서 사라짐: 재현함(랩 P2, #88, 2026-10-27). 타임아웃·deadline 경로: channels-nats 회귀 테스트(이 저장소에서는 코드상 확인) |
967
1012
  | HTTP 는 되는데 웹소켓 연결이 안 된다 | `uvicorn` 만 설치하면 웹소켓 구현이 없다 | `uvicorn[standard]` 를 설치한다([single-server §3](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/single-server.md#dockerfile)) | 출처(uvicorn 0.54.0 메타데이터) |
968
1013
 
969
1014
  ## 개발
@@ -980,8 +1025,12 @@ scripts/ci-local.sh litestream # 실제 litestream 바이너리 + channels-
980
1025
 
981
1026
  scripts/lab.sh run all # 회귀 랩 L1–L8·프로필 종단 검증 (Docker, 약 8분, 끝나면 down -v)
982
1027
  scripts/lab.sh bench # PRAGMA 벤치 (lab/README.md)
1028
+ scripts/lab.sh dbconfig # Django 기본 대 권장 설정 비교 (약 25분, #83)
1029
+
1030
+ # README 차트(docs/assets/*.svg)를 커밋된 CSV(docs/research/data/)에서 다시 만든다. matplotlib 은 의존성이 아니다
1031
+ uv run --no-project --with matplotlib==3.11.2 python docs/assets/charts.py
983
1032
 
984
1033
  gh workflow run lab.yml -f scenarios=all # 회귀 랩을 GitHub 러너(amd64)에서 수동 실행. 결과는 artifact (벤치 제외)
985
1034
  ```
986
1035
 
987
- 릴리스: `CHANGELOG.md` 의 `[Unreleased]` 를 새 버전 절로 옮기고 `pyproject.toml` 의 version 을 올린 뒤 `python3 scripts/release_check.py v<버전>` 이 통과하면 태그 `v<버전>` 을 push 한다. `.github/workflows/release.yml` 이 같은 검사 뒤 PyPI(trusted publishing)와 GitHub 릴리스에 올린다. 산출물은 `uv build --out-dir <디렉터리>` 뒤 `python3 scripts/release_check.py v<버전> --dist <디렉터리>` 로 미리 볼 수 있다(빌드된 Version, PyPI 설명의 상대 링크). 태그 순서: PR 병합 → `git fetch` → `git tag v<버전> origin/main` → 그 커밋에서 `python3 scripts/release_check.py v<버전>` → `git push origin v<버전>`. squash 병합이라 브랜치의 커밋은 main 의 조상이 아니어서, 거기에 붙인 태그는 release.yml 의 조상 검사가 막는다.
1036
+ 릴리스: `CHANGELOG.md` 의 `[Unreleased]` 를 새 버전 절로 옮기고 `pyproject.toml` 의 version 을 올린 뒤 `python3 scripts/release_check.py v<버전>` 이 통과하면 태그 `v<버전>` 을 push 한다. `.github/workflows/release.yml` 이 같은 검사 뒤 PyPI(trusted publishing)와 GitHub 릴리스에 올린다. 산출물은 `uv build --out-dir <디렉터리>` 뒤 `python3 scripts/release_check.py v<버전> --dist <디렉터리>` 로 미리 볼 수 있다(빌드된 Version, PyPI 설명의 상대 링크, GitHub blob 페이지를 가리키는 이미지). README 이미지는 PyPI 에서도 보이게 `raw.githubusercontent.com` 절대 URL 로 건다(상대 경로는 치환으로 blob 페이지가 되어 깨진다). 태그 순서: PR 병합 → `git fetch` → `git tag v<버전> origin/main` → 그 커밋에서 `python3 scripts/release_check.py v<버전>` → `git push origin v<버전>`. squash 병합이라 브랜치의 커밋은 main 의 조상이 아니어서, 거기에 붙인 태그는 release.yml 의 조상 검사가 막는다.
@@ -4,9 +4,54 @@
4
4
 
5
5
  Django 에서 SQLite 를 운영 DB 로 안전하게 쓰게 하는 **운영 도구**다. 권장 설정, 부팅 시 복원 판정, 진단, 복제 헬스, 배포 프로필을 맡는다. DB 백엔드를 바꾸거나 감싸지 않고, 표준 `DATABASES` 설정만 다룬다.
6
6
 
7
+ ## 왜 쓰는가
8
+
9
+ 이 패키지는 실측으로 확인한 권장 설정을 한 줄로 넣고, 운영에서 조용히 망가지는 설정과 상태를 막는다. 아래는 모두 회귀 랩(Docker, colima arm64)에서 잰 값이다. 좋아진 것과 나빠진 것을 함께 적는다. 이득이 재현되지 않은 것(PRAGMA `mmap_size`·`cache_size`·`journal_size_limit`)은 기본값에 넣지 않았다([#26](docs/research/bench-2026-10-08.md), [#27](docs/research/cache-size-2026-10-12.md)).
10
+
11
+ **1. 읽고-고쳐-쓰기 트랜잭션에서 `database is locked` 가 사라진다** ([측정](docs/research/bench-2026-10-26-default-vs-recommended.md), 2026-10-26)
12
+
13
+ ![Django 기본 설정 대 권장 설정의 처리량·p99·locked 오류](https://raw.githubusercontent.com/itda-work/django-sqlite-ops/main/docs/assets/dbconfig.svg)
14
+
15
+ - 측정 조건: Django 기본 설정(rollback journal, DEFERRED)과 `sqlite_database()`(WAL, `IMMEDIATE`, `synchronous=NORMAL`)를 같은 앱·같은 부하(동시 16, uvicorn 워커 1·4)로 5회씩 번갈아 돌렸다. Litestream 은 붙이지 않았다. 아래 범위는 반복별 짝 차이의 최소–최대다.
16
+ - `transaction.atomic()` 안에서 읽고 고쳐 쓰는 요청은 Django 기본에서 **42.9–47.9% 가 `database is locked`(HTTP 500)** 로 실패했다. 권장 설정은 10번의 실행 모두에서 0건이었다.
17
+ - 단독 INSERT(autocommit)만 있는 부하는 **두 설정 모두 locked 0** 이었다. locked 는 읽고-고쳐-쓰기 트랜잭션에서만 났다.
18
+ - 50행 INSERT 가 70% 인 부하에서 권장 설정의 처리량과 p99:
19
+ - 워커 1: 처리량 +64.2–133.0%, p99 −47.7 – −61.6%.
20
+ - 워커 4: 처리량 +115.8–884.6%, p99 −77.3 – −92.9%.
21
+ - 읽기 위주 부하(읽기 70%·정렬 10%·1행 INSERT 20%)에서 권장 설정의 p99 는 워커 1 에서 −56.6 – −74.6%, 워커 4 에서 −82.6 – −86.3% 였다. 처리량은 워커 4 에서 +61.0–90.8% 였다.
22
+ - 다 좋아지지는 않았다(모두 5회 반복에서 재현된 것, 또는 재현되지 않은 것).
23
+ - 읽기 위주 부하의 **p50 은 권장 설정이 높았다**: 워커 1 +19.7–64.7%, 워커 4 +48.2–62.3%(재현).
24
+ - 워커 1 의 읽기 위주 부하에서는 처리량 차이가 재현되지 않았다(−12.5 – +15.9%).
25
+ - 읽고-고쳐-쓰기 단계(읽기 50% + 읽고-고쳐-쓰기 50%)의 p99 는 워커 1 에서 **권장 설정이 높았다**(+36.3–143.6%, 재현). 워커 4 는 재현 안 됨(−33.8 – +3.1%).
26
+ - 요청 단위로는 두 워커 모두 **권장 설정의 읽고-고쳐-쓰기 요청이 더 오래 걸렸다**(성공한 요청의 p99: 워커 1 65.6–106.6 → 148.9–227.5 ms, 워커 4 137.6–212.8 → 231.9–336.3 ms). 기본 설정에서 실패했을 요청이 실패 대신 쓰기 잠금을 기다린 것으로 보인다(추정). 권장 설정이 그 단계를 더 큰 DB 에서 시작한 영향도 섞여 있다([한계](docs/research/bench-2026-10-26-default-vs-recommended.md#한계)).
27
+
28
+ **2. ASGI 에서 `CONN_MAX_AGE=None` 을 막는다** ([#26](docs/research/bench-2026-10-08.md#결과-연결-재사용), 2026-10-08 · [#33](docs/research/fd-soak-2026-10-09.md), 2026-10-09)
29
+
30
+ ![CONN_MAX_AGE 0 대 None 의 열린 DB fd 와 500 오류](https://raw.githubusercontent.com/itda-work/django-sqlite-ops/main/docs/assets/conn-max-age.svg)
31
+
32
+ - uvicorn 의 동기 뷰에서 `CONN_MAX_AGE=None` 은 연결을 재사용하지 않는다(DB 요청마다 새 연결 약 1개). 대신 닫지 않고 쌓는다.
33
+ - 열린 DB 파일 fd 가 기본 한도 1,024 바로 아래(810–982)까지 올라갔다. 그때 `unable to open database file` 500 이 났다.
34
+ - 한도를 올리면 이 부하(동시 16, 30분)에서는 오류가 없었다. 그래도 `CONN_MAX_AGE=0` 보다 fd 를 16.75배, RSS 를 5.62배 많이 썼고 연결 재사용 이득은 없었다.
35
+ - [시스템 체크](#시스템-체크) `sqlite_ops.W005` 가 이 설정을 알린다.
36
+
37
+ **3. 위험한 부팅을 거부하고, 안전할 때만 복원한다** ([회귀 랩](docs/research/lab-2026-10-08.md), 2026-10-08 · [L8c·L8d](docs/research/lab-2026-10-25-long-outage.md), 2026-10-25)
38
+
39
+ Litestream 만 쓰면 옛 볼륨으로 재부팅했을 때 최신 복제본이 덮인다(150건 → 55건, [재현](docs/research/litestream-django-docker.md#d4-부팅-가드-옛-볼륨-재부팅이-최신본을-덮는-문제-막기)). 아래는 [boot CLI](#boot-cli) 가 각 경계에서 한 일이다(모두 Docker 로 재현, arm64·amd64 통과).
40
+
41
+ | 상황 | boot 의 결과 |
42
+ |---|---|
43
+ | L1 볼륨 없는 새 컨테이너 | 복제본에서 복원, 행 수 일치 |
44
+ | L2 옛 볼륨으로 재부팅(복제본이 더 앞섬) | exit 2 `remote_ahead`, 복제본 손상 0 |
45
+ | L3 로컬 메타만 사라짐 | exit 2 `no_local_meta`, DB 무변경 |
46
+ | L4 부팅 중 S3 끊김(거부·무응답) | 타임아웃 뒤 exit 2 `remote_error` |
47
+ | L5 복제되지 않은 로컬 커밋 | 그대로 진행, 커밋 보존 |
48
+ | L6 복원 도중 kill | 반쯤 옮겨진 상태 없음, 재실행으로 완료 |
49
+ | L7 복제본 객체 손상 | exit 4, 로컬 파일 무변경 |
50
+ | L8 S3 끊김 중 헬스 | 업로드만 끊기면 `backlog`, 조회까지 끊기면 `unknown`. 복구 뒤 `caught_up` |
51
+
7
52
  ## 현재 상태
8
53
 
9
- PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](docs/DECISIONS.md), 2026-10-26 결정). 첫 버전은 `0.1.0a1` 이다. 사전 릴리스는 버전을 명시해 설치한다(아래 [설치](#설치)). 바뀐 점은 [CHANGELOG](CHANGELOG.md) 에 적는다. 외부 홍보는 하지 않는다. 패키지 이름 `django-sqlite-ops` 는 확정이다(D-28 이 D-6 을 갱신).
54
+ PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](docs/DECISIONS.md), 2026-10-26 결정). 첫 버전은 `0.1.0a1`(2026-10-26), 지금은 `0.1.0a2` 다. 사전 릴리스는 버전을 명시해 설치한다(아래 [설치](#설치)). 바뀐 점은 [CHANGELOG](CHANGELOG.md) 에 적는다. 외부 홍보는 하지 않는다. 패키지 이름 `django-sqlite-ops` 는 확정이다(D-28 이 D-6 을 갱신).
10
55
 
11
56
  | 기능 | 상태 | 이슈·근거 |
12
57
  |---|---|---|
@@ -28,16 +73,16 @@ PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](docs/DECISIONS.md
28
73
  PyPI 에서 설치한다. 사전 릴리스는 버전을 명시해 설치한다. 실험적 프로젝트이므로 그 버전으로 고정해 둔다.
29
74
 
30
75
  ```bash
31
- pip install "django-sqlite-ops==0.1.0a1"
76
+ pip install "django-sqlite-ops==0.1.0a2"
32
77
  # 또는
33
- uv add "django-sqlite-ops==0.1.0a1"
78
+ uv add "django-sqlite-ops==0.1.0a2"
34
79
  ```
35
80
 
36
- 필수 의존성은 Django 하나다. 채널 레이어는 extra 로 고른다: `[nats]`(channels-nats), `[channels-lite]`(channels-lite 만. aio 레이어에 필요한 의존성은 [channels-lite 패치 '함정'](#channels-lite-패치)).
81
+ 필수 의존성은 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` 처럼 그 패키지를 올린다.
37
82
 
38
83
  ```bash
39
- pip install "django-sqlite-ops[nats]==0.1.0a1"
40
- uv add "django-sqlite-ops[channels-lite]==0.1.0a1"
84
+ pip install "django-sqlite-ops[nats]==0.1.0a2"
85
+ uv add "django-sqlite-ops[channels-lite]==0.1.0a2"
41
86
  ```
42
87
 
43
88
  `pip install --pre` 나 `uv add --prerelease=allow` 는 쓰지 않는다. 이 패키지뿐 아니라 Django 를 비롯한 **모든 의존성**의 사전 릴리스까지 허용한다(`uv add --prerelease=allow` 는 `uv.lock` 에 `prerelease-mode = "allow"` 로 남아 이후의 해석에도 적용된다). 버전을 지정자에 명시하면 그 사전 릴리스만 받아들여진다. 정식 릴리스가 나온 뒤에는 명시하지 않으면 사전 릴리스를 고르지 않는다.
@@ -866,7 +911,7 @@ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른
866
911
  - 한 번만 쓰는 boot 옵션(`--init-new`·`--adopt-existing`·`--on-unknown restore`)은 이미지에 굳히지 않고 `BOOT_FLAGS` 환경 변수로 넘긴 뒤 비운다.
867
912
  - 열린 파일 한도(`nofile`)의 확인·설정(Docker `--ulimit`, compose `ulimits`, systemd `LimitNOFILE=`)·계산법은 두 프로필 공통 문서 [docs/profiles/fd-limits.md](docs/profiles/fd-limits.md) 에 있다. 프로필 compose 예에는 `ulimits` 를 넣지 않았다(이유는 그 문서 §6).
868
913
  - `settings.py`·`urls.py` 조각은 테스트가 실행하고 `check --deploy` 를 통과하는지 본다. `litestream.yml` 은 실제 litestream 0.5.17 이 읽는지, `entrypoint.sh` 는 문법을 본다. Dockerfile·compose 는 회귀 랩이 문서 블록을 그대로 꺼내 빌드하고 띄워 종단 검증했다(첫 배포, `check --deploy`, `sqlite_doctor`, `docker stop` 시 exit 0·복제 400/400, 빈 볼륨 소유자 복사, 워커 2개 — [결과](docs/research/lab-2026-10-08.md)). multiproc 은 channels-nats(`[nats]` extra)를 넣어 워커 사이 전달도 랩에서 확인했다(2026-10-26, 아래). 2026-10-08 의 multiproc 검증은 랩이 nats 컨테이너를 띄우지 않은 채 돈 것이었다.
869
- - **multiproc 채널 전달(랩 P2, 2026-10-26, channels-nats 0.10.0, uvicorn 워커 2)**: 그룹·프로세스 전용 채널은 워커마다 200/200(워커 → 워커 포함), 일반 채널 큐 그룹은 합 200·중복 0. 수신 루프를 남긴 채 `docker stop` 이 exit 0·0.74초로 끝났다. 손실은 계약대로 두 곳에서만 났다: 일반 채널을 `wait_for` 로 폴링할 때 두 폴링 사이(10ms 폴링에서 16/200), 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘칠 때(간격 없는 버스트 200건). 보내는 쪽에는 어느 쪽도 예외가 없다. 아래 '채널 레이어' 함정 두 줄을 본다([결과](docs/research/lab-2026-10-26-multiproc-nats.md)).
914
+ - **multiproc 채널 전달(랩 P2, 2026-10-26 channels-nats 0.10.0, 2026-10-27 0.10.1 로 다시 확인, uvicorn 워커 2)**: 그룹·프로세스 전용 채널은 워커마다 200/200(워커 → 워커 포함), 일반 채널 큐 그룹은 합 200·중복 0. 수신 루프를 남긴 채 `docker stop` 이 exit 0·0.74초로 끝났다. 손실은 계약대로 두 곳에서만 났다: 일반 채널을 `wait_for` 로 폴링할 때 두 폴링 사이(10ms 폴링에서 16/200), 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘칠 때(간격 없는 버스트 200건). 보내는 쪽에는 어느 쪽도 예외가 없다. 0.10.1 에서는 1ms·10ms 폴링 뒤에도 같은 연결이 그룹 메시지를 200/200 받는다(#88, 2026-10-27). 아래 '채널 레이어' 함정을 본다([결과](docs/research/lab-2026-10-26-multiproc-nats.md)).
870
915
 
871
916
  ## 문제 해결
872
917
 
@@ -932,7 +977,7 @@ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른
932
977
  | channels-lite aio 레이어에서 한 메시지가 두 수신자에게 배달된다 | 선점 성공을 누적 `total_changes` 로 판정한다 | `SQLITE_OPS["PATCH_CHANNELS_LITE_AIO"] = True`([channels-lite 패치](#channels-lite-패치)) | 재현함(#8) |
933
978
  | 새 채널 DB 에 여러 프로세스가 처음 붙을 때 `database is locked` | 여러 연결이 동시에 처음 `journal_mode=WAL` 로 바꾸면 일부가 실패한다(원인 미확정) | `migrate` 직후 채널 DB 를 미리 WAL 로 바꿔 둔다 | 재현함(#8) |
934
979
  | 버스트로 보낸 메시지 일부가 사라지는데 경고는 "1 message(s) dropped so far" 한 줄뿐이다 | channels-nats 는 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘치면 버린다(`ChannelFull` 없음). 경고는 60초에 한 번으로 묶이고 합계는 자리가 난 뒤 다음 메시지가 올 때 "has room again after dropping N" 으로 나온다 — 첫 줄만 보면 과소평가한다(실측 1줄 / 실제 1–16건) | 합계 줄을 본다. 한 번에 몰아 보내는 양이 `capacity` 를 넘으면 `capacity` 를 올리거나 나눠 보낸다. channels-nats D2 실측의 196/200 도 같은 모양이라 이것으로 본다(추론) | 재현함(#66) |
935
- | 한 워커가 채널 메시지를 하나도 받지 못한다. 로그에 `nats: encountered error … InvalidStateError` | 구독 확인(`new_channel`·`group_add`·일반 채널 첫 `receive`)이 취소되거나 시간 초과되면 nats-py 2.16.0 의 읽기 루프가 끝나 그 연결이 그룹 메시지까지 받지 못한다. 그 뒤의 `new_channel`·`group_add` 도 예외 없이 성공한다. 일반 채널을 1ms `wait_for` 로 폴링하는 것은 그 한 예다(알려진 결함, 회복 시간 미검증) | 일반 채널을 `wait_for` 로 폴링하지 않는다. 10ms 폴링에서도 재현됐다(랩 RTT 약 0.25ms 에서 P2 6회 중 1회, 2026-10-26 확인). 안전한 폴링 간격은 없다. 브로커가 10초 넘게 멈췄다 살아난 뒤에도 같은 일이 날 수 있다(nats-py flush 타임아웃·레이어 `publish_deadline`). 로그에 위 줄이 있는 워커는 재시작한다. 상류 처리는 [이관 문서](docs/research/handoff-marco-nats-flush-cancel.md) | 1ms·10ms 폴링: 재현함(#66). 타임아웃·deadline 경로: 코드상 확인 |
980
+ | 한 워커가 채널 메시지를 하나도 받지 못한다. 로그에 `nats: encountered error … InvalidStateError` | channels-nats 0.10.0 이하: 구독 확인(`new_channel`·`group_add`·일반 채널 첫 `receive`)이 취소되거나 시간 초과되면 nats-py 2.16.0 의 읽기 루프가 끝나 그 연결이 그룹 메시지까지 받지 못한다. 그 뒤의 `new_channel`·`group_add` 도 예외 없이 성공한다. 일반 채널을 `wait_for` 로 폴링하는 것(1ms 매번, 10ms 도 6회 중 1회), 브로커가 10초 넘게 멈췄다 살아나는 것(nats-py flush 타임아웃·레이어 `publish_deadline`)이 그 예다 | **channels-nats 0.10.1 이상으로 올린다.** 0.1.0a2 부터 `[nats]` 가 `>=0.10.1` 을 요구한다. 0.1.0a1 의 `[nats]` 는 하한이 없어 0.10.0 을 그대로 둘 수 있다. 0.10.0 이하로 고정했거나 lock 에 잠겼으면 그 고정을 풀어 올린다. 0.10.1 은 nats-py 2.16.0 이하일 때 상류 수정(nats-py PR #1031, 병합·미릴리스)과 같은 패치를 넣는다. 올리기 전에는 로그에 위 줄이 있는 워커를 재시작한다. 일반 채널 폴링은 0.10.1 에서도 두 폴링 사이의 메시지를 잃는다(계약) | 0.10.0 의 1ms·10ms 폴링: 재현함(#66). 0.10.1 에서 사라짐: 재현함(랩 P2, #88, 2026-10-27). 타임아웃·deadline 경로: channels-nats 회귀 테스트(이 저장소에서는 코드상 확인) |
936
981
  | HTTP 는 되는데 웹소켓 연결이 안 된다 | `uvicorn` 만 설치하면 웹소켓 구현이 없다 | `uvicorn[standard]` 를 설치한다([single-server §3](docs/profiles/single-server.md#dockerfile)) | 출처(uvicorn 0.54.0 메타데이터) |
937
982
 
938
983
  ## 개발
@@ -949,8 +994,12 @@ scripts/ci-local.sh litestream # 실제 litestream 바이너리 + channels-
949
994
 
950
995
  scripts/lab.sh run all # 회귀 랩 L1–L8·프로필 종단 검증 (Docker, 약 8분, 끝나면 down -v)
951
996
  scripts/lab.sh bench # PRAGMA 벤치 (lab/README.md)
997
+ scripts/lab.sh dbconfig # Django 기본 대 권장 설정 비교 (약 25분, #83)
998
+
999
+ # README 차트(docs/assets/*.svg)를 커밋된 CSV(docs/research/data/)에서 다시 만든다. matplotlib 은 의존성이 아니다
1000
+ uv run --no-project --with matplotlib==3.11.2 python docs/assets/charts.py
952
1001
 
953
1002
  gh workflow run lab.yml -f scenarios=all # 회귀 랩을 GitHub 러너(amd64)에서 수동 실행. 결과는 artifact (벤치 제외)
954
1003
  ```
955
1004
 
956
- 릴리스: `CHANGELOG.md` 의 `[Unreleased]` 를 새 버전 절로 옮기고 `pyproject.toml` 의 version 을 올린 뒤 `python3 scripts/release_check.py v<버전>` 이 통과하면 태그 `v<버전>` 을 push 한다. `.github/workflows/release.yml` 이 같은 검사 뒤 PyPI(trusted publishing)와 GitHub 릴리스에 올린다. 산출물은 `uv build --out-dir <디렉터리>` 뒤 `python3 scripts/release_check.py v<버전> --dist <디렉터리>` 로 미리 볼 수 있다(빌드된 Version, PyPI 설명의 상대 링크). 태그 순서: PR 병합 → `git fetch` → `git tag v<버전> origin/main` → 그 커밋에서 `python3 scripts/release_check.py v<버전>` → `git push origin v<버전>`. squash 병합이라 브랜치의 커밋은 main 의 조상이 아니어서, 거기에 붙인 태그는 release.yml 의 조상 검사가 막는다.
1005
+ 릴리스: `CHANGELOG.md` 의 `[Unreleased]` 를 새 버전 절로 옮기고 `pyproject.toml` 의 version 을 올린 뒤 `python3 scripts/release_check.py v<버전>` 이 통과하면 태그 `v<버전>` 을 push 한다. `.github/workflows/release.yml` 이 같은 검사 뒤 PyPI(trusted publishing)와 GitHub 릴리스에 올린다. 산출물은 `uv build --out-dir <디렉터리>` 뒤 `python3 scripts/release_check.py v<버전> --dist <디렉터리>` 로 미리 볼 수 있다(빌드된 Version, PyPI 설명의 상대 링크, GitHub blob 페이지를 가리키는 이미지). README 이미지는 PyPI 에서도 보이게 `raw.githubusercontent.com` 절대 URL 로 건다(상대 경로는 치환으로 blob 페이지가 되어 깨진다). 태그 순서: PR 병합 → `git fetch` → `git tag v<버전> origin/main` → 그 커밋에서 `python3 scripts/release_check.py v<버전>` → `git push origin v<버전>`. squash 병합이라 브랜치의 커밋은 main 의 조상이 아니어서, 거기에 붙인 태그는 release.yml 의 조상 검사가 막는다.
@@ -1,6 +1,6 @@
1
1
  # django-sqlite-ops 설계서 v1
2
2
 
3
- - 상태: v0.1 범위(§3-1)는 구현·병합됐다(#1~#10, 이후 결함 수정 #38~#44). 남은 것은 2단계(§3-2)와 §12 의 미검증 항목이다. PyPI 에는 실험적 사전 릴리스(`0.1.0a1`)로 배포하고 외부 홍보는 보류한다(D-28, 2026-10-26 확인).
3
+ - 상태: v0.1 범위(§3-1)는 구현·병합됐다(#1~#10, 이후 결함 수정 #38~#44). 남은 것은 2단계(§3-2)와 §12 의 미검증 항목이다. PyPI 에는 실험적 사전 릴리스(`0.1.0a2`)로 배포하고 외부 홍보는 보류한다(D-28, 배포 뒤 확인).
4
4
  - 작성: Django전문가-아이스버그(itda-django), 2026-10-07
5
5
  - 근거: `docs/research/` 의 보고서 6편과 교차 리뷰 원문. 수치는 모두 그 보고서의 실측이다.
6
6
  - 이름 `django-sqlite-ops` 는 확정이다(`docs/DECISIONS.md` D-28 이 D-6 을 갱신, 2026-10-26).
@@ -534,8 +534,8 @@ SQLITE_OPS = {
534
534
  | 디스크 쓰기 / Litestream 영향 | 없음 | 없음(Redis 쪽) |
535
535
  | 1:1 지연 p50 (로컬 실측) | 0.3ms | (이번에는 재지 않음) |
536
536
 
537
- - 컨테이너에서 워커 사이 전달을 확인했다(랩 P2, 2026-10-26, channels-nats 0.10.0·nats-py 2.16.0, uvicorn 워커 2): 그룹·프로세스 전용 채널은 워커마다 200/200(1ms `wait_for` 취소 수천 번 포함, 워커 → 워커 포함), 일반 채널 큐 그룹은 합 200·중복 0. 계약상 허용 손실 두 가지도 재현했다 — 일반 채널 10ms 폴링의 취소 공백(184/200), 간격 없는 버스트의 mailbox `capacity`(100) 드롭(손실 = 드롭 수). D2 의 196/200(`~/Apps/django-lab/runs/radar-spike-sqlite-layer-20261007-161819/verify2.log:8`)도 후자로 본다. 근거는 0.10.0 에서 같은 모양(손실 시도마다 한 워커가 정확히 100건)을 재현한 것, 원 실행 판 `c728f35` 의 용량 로직이 같은 것(코드상 확인)이다. 원 실행 자체의 원인은 이 둘에서 나온 추론이다(조건이 다르다: spawn 프로세스, `sleep(1.5)`, uvicorn 아님)([`lab-2026-10-26-multiproc-nats.md`](research/lab-2026-10-26-multiproc-nats.md) §4).
538
- - **알려진 결함**: 구독 확인(`new_channel`·`group_add`·일반 채널 첫 `receive`)이 취소되거나 시간 초과되면 nats-py 읽기 루프가 `InvalidStateError` 로 끝나 그 연결은 그룹 메시지까지 받지 못한다. 그 뒤의 구독은 확인 없이 성공한다. 1ms `wait_for` 폴링은 재현함(매번), 10ms 폴링도 재현함(2026-10-26 확인, P2 6회 중 1회 — 안전한 폴링 간격은 없다). nats-py flush 타임아웃·`publish_deadline` 경로는 코드상 확인. 랩이 1ms 판을 strict xfail 로 고정하고, 10ms·D2 판은 별도 레이어 인스턴스에서 결함 발생을 기록만 한다(`defect_ok`). 외부 변경은 마르코 이관 문서([`handoff-marco-nats-flush-cancel.md`](research/handoff-marco-nats-flush-cancel.md))로 넘겼다.
537
+ - 컨테이너에서 워커 사이 전달을 확인했다(랩 P2, 2026-10-26 channels-nats 0.10.0, 2026-10-27 0.10.1 로 다시 확인 — 둘 다 nats-py 2.16.0, uvicorn 워커 2): 그룹·프로세스 전용 채널은 워커마다 200/200(1ms `wait_for` 취소 수천 번 포함, 워커 → 워커 포함), 일반 채널 큐 그룹은 합 200·중복 0. 계약상 허용 손실 두 가지도 재현했다 — 일반 채널 10ms 폴링의 취소 공백(184/200), 간격 없는 버스트의 mailbox `capacity`(100) 드롭(손실 = 드롭 수). D2 의 196/200(`~/Apps/django-lab/runs/radar-spike-sqlite-layer-20261007-161819/verify2.log:8`)도 후자로 본다. 근거는 0.10.0 에서 같은 모양(손실 시도마다 한 워커가 정확히 100건)을 재현한 것, 원 실행 판 `c728f35` 의 용량 로직이 같은 것(코드상 확인)이다. 원 실행 자체의 원인은 이 둘에서 나온 추론이다(조건이 다르다: spawn 프로세스, `sleep(1.5)`, uvicorn 아님)([`lab-2026-10-26-multiproc-nats.md`](research/lab-2026-10-26-multiproc-nats.md) §4).
538
+ - **channels-nats 0.10.0 이하의 결함 — 0.10.1 에서 막힘**: 구독 확인(`new_channel`·`group_add`·일반 채널 첫 `receive`)이 취소되거나 시간 초과되면 nats-py 2.16.0 이하의 읽기 루프가 `InvalidStateError` 로 끝나 그 연결은 그룹 메시지까지 받지 못한다. 그 뒤의 구독은 확인 없이 성공한다. 0.10.0 에서 1ms `wait_for` 폴링은 재현함(매번), 10ms 폴링도 재현함(P2 6회 중 1회, 2026-10-26). channels-nats 0.10.1(2026-10-27)이 nats-py 2.16.0 이하일 때 `Client._process_pong` 을 상류 수정(nats-py PR #1031, 병합·미릴리스)과 같은 코드로 바꾼다. 랩을 0.10.1 로 올리자 1ms 판 strict xfail 이 XPASS 로 알렸고, 결함 예외를 걷어 낸 P2(1ms·10ms 폴링 뒤 같은 연결의 그룹 수신 200/200, D2 20회 × 2)가 보통 단언으로 통과한다(#88, 2026-10-27 확인). nats-py flush 타임아웃·`publish_deadline` 경로는 channels-nats 저장소의 회귀 테스트가 본다(이 저장소에서는 재현하지 않음). 사용자에게는 `channels-nats>=0.10.1` 을 권한다(README·multiproc 프로필). 이관 기록: [`handoff-marco-nats-flush-cancel.md`](research/handoff-marco-nats-flush-cancel.md).
539
539
  - SQLite 기반 레이어(channels-lite)는 추천하지 않는다. 근거는 다음과 같다.
540
540
  - 폴링 간격과 유휴 CPU 를 맞바꿔야 한다. 0.1초 간격이면 p50 51ms·유휴 CPU 59ms/10초, 0.001초 간격이면 p50 2.3ms·유휴 CPU 1.9초/10초(코어의 약 19%)다.
541
541
  - 메시지마다 DB 쓰기가 2번 생긴다.
@@ -585,9 +585,10 @@ SQLITE_OPS = {
585
585
  | L8 | 헬스: S3 20초 끊김 | `backlog` → 복구 후 `caught_up` | 조건부: 업로드만 끊기면(L8b) `backlog` → `caught_up`. 조회까지 끊기면(L8a) 랩의 허용 시간 10초(가정: `REFRESH × 3` + 진행 중 조회 여유 `REFRESH` + 표본 간격 + 1초. 조회 시간에서 도출된 보장 상한은 아니다) 안에 `unknown`(`stale`)으로 바뀌어 유지 → `caught_up` — §7 표대로 원격 조회 실패는 `unknown` 이 먼저다 |
586
586
  | L8c·L8d | 헬스: S3 90초 끊김(조회 타임아웃 30초의 3배) — 연결 거부 / 양방향 무응답(`timeout` toxic, `downstream`+`upstream`) | `unknown` 유지(`stale` 과 `remote_error` 가 `REFRESH + 30` 초 주기로 번갈아), strict 503, 매달린 `ltx` 없음 → 복구 후 `caught_up` | 통과(2026-10-25, colima arm64 에서 L8c 3회·양방향 L8d 2회, GitHub Actions amd64 run 40196113088 에서 1회씩): 첫 `stale` 5.0–5.1초, `remote_error` 구간 31.3–31.6·63.0–64.0초 시작, `ltx` 동시 최대 1·서로 다른 PID 3·복구 뒤 0, 쓰기 오류 0, 복구 2.0–7.1초 뒤 `caught_up`. 단절 중 Litestream 로그 0줄(WARN/ERROR 포함). 처음 두 L8d 실행은 toxic 이 `downstream` 하나라 응답 유실만이었다([`lab-2026-10-25-long-outage.md`](research/lab-2026-10-25-long-outage.md)) |
587
587
 
588
- 배포 프로필 종단 검증(P1·P2): 문서의 Dockerfile·compose·entrypoint·settings 를 그대로 띄워 첫 배포·`check --deploy`·`sqlite_doctor`·`docker stop`(uvicorn 워커 1·2: exit 0, 대표 실행 0.47·0.72초, 400/400 복제)·재부팅·빈 볼륨 소유자 복사를 확인했다. 2026-10-08 의 P2 는 **nats 없이** 돌았다(랩이 앱을 `--no-deps` 로 띄워 `depends_on` 이 무시됨, 2026-10-26 재현). #66 부터 P2 는 nats 를 먼저 띄우고 channels-nats(`[nats]` extra, 0.10.0 고정)를 넣은 이미지로 워커 사이 전달·손실·중복·종료를 단언한다: 수신 루프를 남긴 채 stop exit 0·0.74초. 1ms 폴링 결함은 `test_P2_nats_flush_cancel_known_defect`(strict xfail)([`lab-2026-10-26-multiproc-nats.md`](research/lab-2026-10-26-multiproc-nats.md)).
588
+ 배포 프로필 종단 검증(P1·P2): 문서의 Dockerfile·compose·entrypoint·settings 를 그대로 띄워 첫 배포·`check --deploy`·`sqlite_doctor`·`docker stop`(uvicorn 워커 1·2: exit 0, 대표 실행 0.47·0.72초, 400/400 복제)·재부팅·빈 볼륨 소유자 복사를 확인했다. 2026-10-08 의 P2 는 **nats 없이** 돌았다(랩이 앱을 `--no-deps` 로 띄워 `depends_on` 이 무시됨, 2026-10-26 재현). #66 부터 P2 는 nats 를 먼저 띄우고 channels-nats(`[nats]` extra, 랩이 판 고정 — #66 0.10.0, #88 부터 0.10.1)를 넣은 이미지로 워커 사이 전달·손실·중복·종료를 단언한다: 수신 루프를 남긴 채 stop exit 0·0.74초. 1ms 폴링 결함은 #66 에서 strict xfail 로 두었다가 0.10.1 에서 사라져(#88, 2026-10-27) 보통 시나리오 P-cancel-1ms 로 옮겼다([`lab-2026-10-26-multiproc-nats.md`](research/lab-2026-10-26-multiproc-nats.md)).
589
589
 
590
590
  - **PRAGMA 벤치**: §6-0 의 후보(`temp_store`, `mmap_size`, `cache_size`, `journal_size_limit`)를 켠 경우와 끈 경우의 처리량·p99·WAL 크기를 잰다. 차이가 재현될 때만 기본값으로 올린다. 2026-10-08 결과(반복 5): 넷 중 개선이 반복해서 재현된 것은 없고, `cache_size=-65536` 단독은 반복해서 나빠졌다. 기본값은 바꾸지 않는다([`lab-2026-10-08.md`](research/lab-2026-10-08.md#pragma-벤치)). 그 원인(#27)은 최소 재현에서 재현하고(기준 고정 뒤 재실행으로 확인) 소스로 확인했다: 캐시 상한을 올리면 상한에 닿기 전에는 페이지 버퍼를 재활용하지 않으므로, 많은 페이지를 같은 연결에서 다시 읽지 않는 요청은 할당·해제만 늘어난다. 같은 페이지를 다시 읽는 요청에서는 반대로 좋아진다([`cache-size-2026-10-12.md`](research/cache-size-2026-10-12.md)). 재측정(#26: `CONN_MAX_AGE` 0·None, WAL 이 자라는 쓰기 단계, 서버 시간·포화 곡선)에서도 재현된 차이는 없었다. 이 계측 조건에서 처리량이 포화했고 부하 발생기에는 여유가 있었다. 앱 CPU/GIL 병목이 의심되지만 원인은 미검증이다([`bench-2026-10-08.md`](research/bench-2026-10-08.md)).
591
+ - **기본 대 권장 비교(#83)**: Django 기본 SQLite 설정과 `sqlite_database()` 를 Litestream 없는 같은 앱·같은 부하로 번갈아 잰다(`scripts/lab.sh dbconfig`). 프로필 스택은 Litestream 이 WAL 로 바꿔 Django 기본을 잴 수 없다(재현함). 2026-10-26 결과(반복 5): 읽고-고쳐-쓰기 트랜잭션에서 기본은 요청의 42.9–47.9% 가 `database is locked`, 권장은 0. 단독 INSERT 는 두 설정 모두 locked 0. 쓰기 부하의 처리량·p99 는 권장이 재현해서 좋았다. 불리한 결과도 재현됐다: 읽기 위주 부하의 p50 은 권장이 높았고, 읽고-고쳐-쓰기 요청의 지연(두 워커)과 그 단계 p99(워커 1)도 권장이 높았다(실패 대신 대기로 보임 — 추정, [`bench-2026-10-26-default-vs-recommended.md`](research/bench-2026-10-26-default-vs-recommended.md)).
591
592
  - 랩 자원은 compose 프로젝트명(`dso-lab*`)과 라벨(`io.itda.dso-lab=1`)로 구분하고, 끝나면 반드시 `down -v` 한다(실패해도 `trap`). 같은 호스트에 다른 프로젝트 컨테이너가 있다.
592
593
  - ASGI(uvicorn) 엔트리포인트로 돌린다. 스파이크는 WSGI(gunicorn)만 검증했다.
593
594
  - L6 의 kill 지점은 `lab_hooks/sitecustomize.py` 가 rename 뒤마다 잠들어 맞춘다(패키지 코드는 그대로). L7 은 SeaweedFS filer API 로 LTX 객체 본문을 뒤집는다.
@@ -607,7 +608,7 @@ SQLITE_OPS = {
607
608
  - 실제 클라우드 S3·R2·Tigris (미검증. 비용이 들어 마스터 승인 필요)
608
609
  - 회귀 랩은 SeaweedFS 4.48 + toxiproxy 2.12.0 으로만 돌았다(colima linux/arm64 와 GitHub Actions ubuntu-latest linux/amd64, 둘 다 11/11 통과 — [`lab-2026-10-08.md`](research/lab-2026-10-08.md#amd64-실행-github-actions-25)). 다른 S3 구현은 미검증
609
610
  - 긴 S3 단절(#65, 2026-10-25): 90초 단절의 `stale` ↔ `remote_error` 전이와 Litestream 로그(WARN/ERROR 0줄)는 L8c·L8d 로 재현했다(colima arm64). GitHub Actions amd64 러너(run 40196113088, 2026-10-25, 양방향 L8d 포함 4 passed)에서도 같았다. 남은 것: 별칭이 여럿인 컨테이너에서 한 별칭의 매달림이 다른 별칭에 주는 영향(코드상 확인·주입 시계 단언만), 기본 `REFRESH` 15초에서의 실제 시계열, 수십 분 이상의 단절 (미검증)
610
- - `single-server-multiproc` 채널 레이어(#66, 2026-10-26): 워커 2개 사이 전달은 랩 P2 로 확인했다(colima arm64). 남은 것: GitHub Actions amd64 에서의 P2, 워커 3개 이상·큰 메시지·브로커 재시작 중 전달, 1ms 폴링 결함의 회복 시간과 실제 컨슈머 경로(구독 확인 중 취소), 일반 채널 취소 뒤 서버에 남은 구독 1회 관측 (미검증)
611
+ - `single-server-multiproc` 채널 레이어(#66, 2026-10-26): 워커 2개 사이 전달은 랩 P2 로 확인했다(colima arm64). GitHub Actions amd64 에서도 통과했다(run [40216748919](https://github.com/itda-work/django-sqlite-ops/actions/runs/40216748919)·[40255449318](https://github.com/itda-work/django-sqlite-ops/actions/runs/40255449318)·[40261505022](https://github.com/itda-work/django-sqlite-ops/actions/runs/40261505022), 2026-10-26 — 1ms 폴링 결함은 strict xfail 로 XFAIL). 1ms 폴링 결함의 회복은 ping stale 감지로 재접속하는 것으로 재현함: 축소 설정(`ping_interval=1`, `max_outstanding_pings=2`)에서 3.0초, 기본값은 코드상 최대 약 6분(3 × 120초). 일반 채널 취소 뒤 서버에 남은 구독(#66 의 1회 관측)은 9회 시도 모두 재현되지 않았다(둘 다 [channels-nats #41 검토](https://github.com/itda-work/channels-nats/issues/41#issuecomment-6409226159), 2026-10-26 확인). 남은 것: 워커 3개 이상·큰 메시지·브로커 재시작 중 전달, 실제 컨슈머 경로(구독 확인 중 취소), 기본값에서의 실제 회복 시간 (미검증). 1ms 폴링 결함은 channels-nats 0.10.1 의 레이어 패치로 랩에서 사라졌다(#88, 2026-10-27 확인 — colima arm64 6회, GitHub Actions amd64 run [40500180645](https://github.com/itda-work/django-sqlite-ops/actions/runs/40500180645) 모두 통과). nats-py 상류 수정(PR #1031)은 병합, 미릴리스 — 고친 nats-py 가 나오면 0.10.1 의 패치가 꺼지므로 랩의 nats-py 판이 바뀐 상태로 P2 를 다시 돌린다
611
612
  - Windows 에서 boot CLI 의 파일 잠금과 디렉터리 rename 원자성 [확인 필요]. 그때까지 boot 는 `fcntl` 이 없으면 exit 64 로 거부한다
612
613
  - "같은 이력" 증명 방법(자동 판정의 전제) [확인 필요]
613
614
  - `--restore-test`(§6-2) 의 두 전제는 #54 에서 재현했다(2026-10-15): `restore -o` 는 `DB_PATH` 쪽을 읽지도 쓰지도 않고, `interrupt()` 는 707MB full 검사를 1ms 안에 멈춘다. 남은 것: 페이지 캐시가 차가운 상태·수십 GB DB·Linux 에서의 `interrupt()` 지연, S3 복제본에서의 불변 계약(file 복제본으로만 확인), litestream VFS 별칭에 로컬 캐시 경로가 있는지(있다면 보호 대상에 넣어야 한다 — 지금은 `vfs` 를 로컬 경로 없음으로 통과시킨다) (미검증)