From c69bb92c9390d81ff8fbb8c1ae9c11096808f10e Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 22 Jun 2026 16:19:38 +0200 Subject: [PATCH 1/3] build(backend): add dbos 2.24.0 dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the DBOS foundation slice (see SPEC.md / tasks/plan.md). dbos 2.24.0 ships a Python 3.13 wheel; verified import on 3.13.5. Additive only — Celery/Redis untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/pyproject.toml | 1 + backend/uv.lock | 143 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3d8df373c6..76dbe61e18 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -84,6 +84,7 @@ dependencies = [ "wagtail==7.3.2", "wagtail-localize==1.13", "celery==5.6.3", + "dbos==2.24.0", "wagtail-headless-preview==0.8.0", "Jinja2>=3.1.6", "icalendar>=5.0.11", diff --git a/backend/uv.lock b/backend/uv.lock index 4b00a0ec09..5812ff1dd0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -144,6 +144,7 @@ dependencies = [ { name = "countries" }, { name = "cryptography" }, { name = "dal-admin-filters" }, + { name = "dbos" }, { name = "django" }, { name = "django-autocomplete-light" }, { name = "django-environ" }, @@ -233,6 +234,7 @@ requires-dist = [ { name = "countries", specifier = ">=0.2.0,<1.0.0" }, { name = "cryptography", specifier = ">=48.0.1" }, { name = "dal-admin-filters", specifier = "==1.1.0" }, + { name = "dbos", specifier = "==2.24.0" }, { name = "django", specifier = "==5.2.8" }, { name = "django-autocomplete-light", specifier = "==3.9.4" }, { name = "django-environ", specifier = "==0.10.0" }, @@ -779,6 +781,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/8e/af83c87628c002b6e7f2f5a414e4ef7364cb3f0cc8af248698d9f894b54c/dal_admin_filters-1.1.0-py3-none-any.whl", hash = "sha256:40be33e446b22eb23e914a6174831420bcbf632cdd5220a9249c869d0e9d797d", size = 7470 }, ] +[[package]] +name = "dbos" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psycopg", extra = ["binary"] }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "typer-slim" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/f9/9ba23c70b9bffe15a4a35b1f1ce1ab31ea62a7ba8b8ad335a6e3128c7084/dbos-2.24.0.tar.gz", hash = "sha256:cf4c687ae8b7b6556723905c0c3ecd7f2d9cd4d27d7c816d10fae6e979ba03aa", size = 507075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f6/788f89785cee5289c13c3220a26ecdc2e90e0d0c549adc342ebfdbfb50b4/dbos-2.24.0-py3-none-any.whl", hash = "sha256:8e72b5fbc0c793214a6bb137cdb780ed08418153ce0afef56c7df2be8f3372ca", size = 210505 }, +] + [[package]] name = "decorator" version = "5.1.1" @@ -1318,6 +1337,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/dc/078bd6b304de790618ebb95e2aedaadb78f4527ac43a9ad8815f006636b6/graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a", size = 203189 }, ] +[[package]] +name = "greenlet" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178 }, + { url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900 }, + { url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265 }, + { url = "https://files.pythonhosted.org/packages/6a/7e/28f991affb413b232b1e7d768db24c37b3f4d5daecc3f19b455d40bd2dea/greenlet-3.5.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e", size = 625044 }, + { url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187 }, + { url = "https://files.pythonhosted.org/packages/29/05/0cc9ec660e7acff85f93b0a048b6654371c822c884add44c02a465cf70e0/greenlet-3.5.2-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5", size = 427322 }, + { url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778 }, + { url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092 }, + { url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352 }, + { url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635 }, + { url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676 }, + { url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552 }, + { url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756 }, + { url = "https://files.pythonhosted.org/packages/23/5c/2664d290cbd1fef9eb3f69b5d3bc5aa91b6fa907519298ca6af93a90c6cb/greenlet-3.5.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", size = 669989 }, + { url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228 }, + { url = "https://files.pythonhosted.org/packages/42/d4/fcb53fa9847d7fbd4723fbed9469c3869b9e3544c4e001d9d5aa2f66162d/greenlet-3.5.2-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", size = 472888 }, + { url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723 }, + { url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227 }, + { url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257 }, + { url = "https://files.pythonhosted.org/packages/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038 }, + { url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668 }, + { url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820 }, + { url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697 }, + { url = "https://files.pythonhosted.org/packages/35/ad/9b3058f999b81750a9c6d9ec424f509462d232b58002086fe2ba63b66407/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", size = 658945 }, + { url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436 }, + { url = "https://files.pythonhosted.org/packages/92/75/1b6ecd8c027b69ab1b6798a84094df79aab5e69ac7e249c78b9d361dd1fa/greenlet-3.5.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", size = 490529 }, + { url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193 }, + { url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512 }, + { url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145 }, + { url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315 }, + { url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130 }, + { url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724 }, + { url = "https://files.pythonhosted.org/packages/53/e0/4ce3a046b51e53934eae93d7f9c13975a97285741e9e1fcadf8751314c37/greenlet-3.5.2-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", size = 673494 }, + { url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089 }, + { url = "https://files.pythonhosted.org/packages/52/e0/9c18721e63445dce02ee67e4c81c0f281626604ff55ae6f7b7f4354d7129/greenlet-3.5.2-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", size = 479721 }, + { url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684 }, + { url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043 }, + { url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531 }, + { url = "https://files.pythonhosted.org/packages/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579 }, + { url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697 }, + { url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710 }, + { url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629 }, + { url = "https://files.pythonhosted.org/packages/55/87/10776cd88df54d0f563e9e21e98363f2d6af94bedc553b1da0972fa87f80/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", size = 663191 }, + { url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147 }, + { url = "https://files.pythonhosted.org/packages/02/aa/26ddf92826a99d87bfb8fdb8f3a262a6f16495a5d8e579737baa92fb4543/greenlet-3.5.2-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", size = 498199 }, + { url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675 }, + { url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577 }, + { url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482 }, + { url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062 }, +] + [[package]] name = "gunicorn" version = "23.0.0" @@ -2338,10 +2414,38 @@ wheels = [ ] [package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] c = [ { name = "psycopg-c", marker = "implementation_name != 'pypy'" }, ] +[[package]] +name = "psycopg-binary" +version = "3.2.12" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/0b/9d480aba4a4864832c29e6fc94ddd34d9927c276448eb3b56ffe24ed064c/psycopg_binary-3.2.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:442f20153415f374ae5753ca618637611a41a3c58c56d16ce55f845d76a3cf7b", size = 4017829 }, + { url = "https://files.pythonhosted.org/packages/a4/f3/0d294b30349bde24a46741a1f27a10e8ab81e9f4118d27c2fe592acfb42a/psycopg_binary-3.2.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:79de3cc5adbf51677009a8fda35ac9e9e3686d5595ab4b0c43ec7099ece6aeb5", size = 4089835 }, + { url = "https://files.pythonhosted.org/packages/82/d4/ff82e318e5a55d6951b278d3af7b4c7c1b19344e3a3722b6613f156a38ea/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:095ccda59042a1239ac2fefe693a336cb5cecf8944a8d9e98b07f07e94e2b78d", size = 4625474 }, + { url = "https://files.pythonhosted.org/packages/b1/e8/2c9df6475a5ab6d614d516f4497c568d84f7d6c21d0e11444468c9786c9f/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:efab679a2c7d1bf7d0ec0e1ecb47fe764945eff75bb4321f2e699b30a12db9b3", size = 4720350 }, + { url = "https://files.pythonhosted.org/packages/74/f5/7aec81b0c41985dc006e2d5822486ad4b7c2a1a97a5a05e37dc2adaf1512/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d369e79ad9647fc8217cbb51bbbf11f9a1ffca450be31d005340157ffe8e91b3", size = 4411621 }, + { url = "https://files.pythonhosted.org/packages/fc/15/d3cb41b8fa9d5f14320ab250545fbb66f9ddb481e448e618902672a806c0/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eedc410f82007038030650aa58f620f9fe0009b9d6b04c3dc71cbd3bae5b2675", size = 3863081 }, + { url = "https://files.pythonhosted.org/packages/69/8a/72837664e63e3cd3aa145cedcf29e5c21257579739aba78ab7eb668f7d9c/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bae4be7f6781bf6c9576eedcd5e1bb74468126fa6de991e47cdb1a8ea3a42a", size = 3537428 }, + { url = "https://files.pythonhosted.org/packages/cc/7e/1b78ae38e7d69e6d7fb1e2dcce101493f5fa429480bac3a68b876c9b1635/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8ffe75fe6be902dadd439adf4228c98138a992088e073ede6dd34e7235f4e03e", size = 3585981 }, + { url = "https://files.pythonhosted.org/packages/a3/f8/245b4868b2dac46c3fb6383b425754ae55df1910c826d305ed414da03777/psycopg_binary-3.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:2598d0e4f2f258da13df0560187b3f1dfc9b8688c46b9d90176360ae5212c3fc", size = 2912929 }, + { url = "https://files.pythonhosted.org/packages/5c/5b/76fbb40b981b73b285a00dccafc38cf67b7a9b3f7d4f2025dda7b896e7ef/psycopg_binary-3.2.12-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dc68094e00a5a7e8c20de1d3a0d5e404a27f522e18f8eb62bbbc9f865c3c81ef", size = 4016868 }, + { url = "https://files.pythonhosted.org/packages/0e/08/8841ae3e2d1a3228e79eaaf5b7f991d15f0a231bb5031a114305b19724b1/psycopg_binary-3.2.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2d55009eeddbef54c711093c986daaf361d2c4210aaa1ee905075a3b97a62441", size = 4090508 }, + { url = "https://files.pythonhosted.org/packages/05/de/a41f62230cf4095ae4547eceada218cf28c17e7f94376913c1c8dde9546f/psycopg_binary-3.2.12-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:66a031f22e4418016990446d3e38143826f03ad811b9f78f58e2afbc1d343f7a", size = 4629788 }, + { url = "https://files.pythonhosted.org/packages/45/19/529d92134eae44475f781a86d58cdf3edd0953e17c69762abf387a9f2636/psycopg_binary-3.2.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:58ed30d33c25d7dc8d2f06285e88493147c2a660cc94713e4b563a99efb80a1f", size = 4724124 }, + { url = "https://files.pythonhosted.org/packages/5c/f5/97344e87065f7c9713ce213a2cff7732936ec3af6622e4b2a88715a953f2/psycopg_binary-3.2.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e0b5ccd03ca4749b8f66f38608ccbcb415cbd130d02de5eda80d042b83bee90e", size = 4411340 }, + { url = "https://files.pythonhosted.org/packages/b1/c2/34bce068f6bfb4c2e7bb1187bb64a3f3be254702b158c4ad05eacc0055cf/psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:909de94de7dd4d6086098a5755562207114c9638ec42c52d84c8a440c45fe084", size = 3867815 }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c647e01ab162e6bfa52380e23e486215e9d28ffd31e9cf3cb1e9ca59008b/psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7130effd0517881f3a852eff98729d51034128f0737f64f0d1c7ea8343d77bd7", size = 3541756 }, + { url = "https://files.pythonhosted.org/packages/6b/d0/795bdaa8c946a7b7126bf7ca8d4371eaaa613093e3ec341a0e50f52cbee2/psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89b3c5201ca616d69ca0c3c0003ca18f7170a679c445c7e386ebfb4f29aa738e", size = 3587950 }, + { url = "https://files.pythonhosted.org/packages/53/cf/10c3e95827a3ca8af332dfc471befec86e15a14dc83cee893c49a4910dad/psycopg_binary-3.2.12-cp314-cp314-win_amd64.whl", hash = "sha256:48a8e29f3e38fcf8d393b8fe460d83e39c107ad7e5e61cd3858a7569e0554a39", size = 3005787 }, +] + [[package]] name = "psycopg-c" version = "3.2.12" @@ -3067,6 +3171,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184 }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735 }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756 }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055 }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850 }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721 }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615 }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999 }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539 }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545 }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929 }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888 }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551 }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318 }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920 }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534 }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844 }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355 }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591 }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313 }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280 }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334 }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "sqlparse" version = "0.5.4" From 1f9ac3a65d1ad1121cd3b260a0ebb8755d20b6b5 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 22 Jun 2026 16:26:57 +0200 Subject: [PATCH 2/3] feat(backend): DBOS config, healthcheck workflow + tests Phase A of the DBOS foundation (SPEC.md / tasks/plan.md): - pycon/dbos_app.py: build_dbos_config() from Django settings + lazy get_dbos_client() for the web tier (enqueue-only, never launches DBOS). - pycon/dbos_workflows.py: minimal healthcheck workflow + step (proof only, not wired into any request path). - settings: DBOS_APP_NAME, DBOS_SYSTEM_DATABASE_URL (postgresql:// scheme), DBOS_SYS_DB_POOL_SIZE=5 (low on purpose; shared Postgres). - tests: in-process exec, idempotency (step runs once under same workflow id), and config-build. Run against a dedicated dbos_test Postgres DB. SQLite is unusable as a DBOS system DB in 2.24.0 (migrations are raw Postgres DDL), so tests use a real Postgres database. Additive only; Celery untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/pycon/dbos_app.py | 47 +++++++++++++++ backend/pycon/dbos_workflows.py | 35 +++++++++++ backend/pycon/settings/base.py | 11 ++++ backend/pycon/tests/test_dbos.py | 100 +++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 backend/pycon/dbos_app.py create mode 100644 backend/pycon/dbos_workflows.py create mode 100644 backend/pycon/tests/test_dbos.py diff --git a/backend/pycon/dbos_app.py b/backend/pycon/dbos_app.py new file mode 100644 index 0000000000..62889922cd --- /dev/null +++ b/backend/pycon/dbos_app.py @@ -0,0 +1,47 @@ +"""Central DBOS configuration for the backend. + +Single source of truth for ``DBOSConfig``. The dedicated ``dbos_worker`` +management command launches DBOS with :func:`build_dbos_config`; the web tier +uses :func:`get_dbos_client` to *enqueue* workflows without ever launching DBOS. + +DBOS runs alongside Celery (purely additive) and stores its workflow state in a +separate ``dbos`` database on the existing Postgres server. See SPEC.md. +""" + +from __future__ import annotations + +from dbos import DBOSClient, DBOSConfig +from django.conf import settings + + +def build_dbos_config() -> DBOSConfig: + """Build the DBOSConfig from Django settings. + + Used by the worker process to configure and launch DBOS. ``run_admin_server`` + is disabled for now to avoid binding an extra port (see SPEC.md open items). + """ + config: DBOSConfig = { + "name": settings.DBOS_APP_NAME, + "system_database_url": settings.DBOS_SYSTEM_DATABASE_URL, + "sys_db_pool_size": settings.DBOS_SYS_DB_POOL_SIZE, + "run_admin_server": False, + } + return config + + +_client: DBOSClient | None = None + + +def get_dbos_client() -> DBOSClient: + """Return a lazily-created, process-wide ``DBOSClient`` for the web tier. + + The client enqueues workflows by name against the DBOS system database. It + never calls ``DBOS.launch()`` (only the dedicated worker process does). The + singleton is reused across calls and torn down at process exit. + """ + global _client + if _client is None: + _client = DBOSClient( + system_database_url=settings.DBOS_SYSTEM_DATABASE_URL, + ) + return _client diff --git a/backend/pycon/dbos_workflows.py b/backend/pycon/dbos_workflows.py new file mode 100644 index 0000000000..2ee07de838 --- /dev/null +++ b/backend/pycon/dbos_workflows.py @@ -0,0 +1,35 @@ +"""DBOS proof-of-concept workflow. + +A minimal ``healthcheck`` workflow used to prove that DBOS launches and executes +a durable, idempotent workflow end to end. It is intentionally *not* wired into +any request path, signal, or Celery task — this is the single proof the +foundation slice owes (see SPEC.md). Real task migration is out of scope here. +""" + +from __future__ import annotations + +from dbos import DBOS + + +def _perform_check() -> str: + """The unit of work the healthcheck performs. + + Pulled out as a plain function so tests can observe how many times it runs: + re-invoking the workflow under the same workflow ID must execute this once, + proving DBOS's exactly-once / idempotent behaviour. + """ + return "ok" + + +@DBOS.step() +def healthcheck_step() -> str: + """Single checkpointed step. Its result is recorded for recovery.""" + return _perform_check() + + +@DBOS.workflow() +def healthcheck() -> str: + """Run one step and return its result.""" + result = healthcheck_step() + DBOS.logger.info(f"healthcheck workflow {DBOS.workflow_id} -> {result}") + return result diff --git a/backend/pycon/settings/base.py b/backend/pycon/settings/base.py index 883498df30..8bc09ac482 100644 --- a/backend/pycon/settings/base.py +++ b/backend/pycon/settings/base.py @@ -403,6 +403,17 @@ CELERY_TASK_IGNORE_RESULT = True +# DBOS (durable workflows). Runs alongside Celery; stores workflow state in a +# separate `dbos` database on the existing Postgres server. The dedicated +# `dbos_worker` management command launches DBOS with this config. See SPEC.md. +# NOTE: this URL uses the SQLAlchemy `postgresql://` scheme, not django-environ's +# `psql://` used by DATABASE_URL. +DBOS_APP_NAME = env("DBOS_APP_NAME", default="pycon") +DBOS_SYSTEM_DATABASE_URL = env("DBOS_SYSTEM_DATABASE_URL", default="") +# Kept low on purpose: the single Postgres instance is shared with Django, +# Celery and the DBOS web client. See SPEC.md connection-budget note. +DBOS_SYS_DB_POOL_SIZE = env.int("DBOS_SYS_DB_POOL_SIZE", default=5) + AWS_STORAGE_BUCKET_NAME = env("AWS_MEDIA_BUCKET", default=None) AWS_REGION_NAME = AWS_SES_REGION_NAME = AWS_S3_REGION_NAME = env( "AWS_REGION_NAME", default="eu-central-1" diff --git a/backend/pycon/tests/test_dbos.py b/backend/pycon/tests/test_dbos.py new file mode 100644 index 0000000000..5f080b3e5c --- /dev/null +++ b/backend/pycon/tests/test_dbos.py @@ -0,0 +1,100 @@ +"""In-process tests for the DBOS foundation. + +These run against a dedicated ``dbos_test`` Postgres database on the same server +as the app database (auto-created if missing, reset before each test). DBOS uses +its own SQLAlchemy engine and does not touch the Django ORM, so these tests do +not request the pytest-django ``db`` fixture and are unaffected by its +transactional rollback. See SPEC.md / tasks/plan.md (Phase A). + +Note: a SQLite system DB is NOT usable here — DBOS 2.24.0's migrations are raw +Postgres DDL (``EXTRACT(epoch FROM now())`` defaults, ``gen_random_uuid()``, +``JSON[]``), so SQLite cannot honour them. Hence a real Postgres test DB. +""" + +import os +from urllib.parse import urlsplit, urlunsplit + +import psycopg +import pytest +from django.conf import settings +from django.test import override_settings + +from dbos import DBOS, DBOSConfig, SetWorkflowID + +from pycon import dbos_workflows +from pycon.dbos_app import build_dbos_config + + +def _dbos_test_system_database_url() -> str: + """Build the DBOS test system DB URL from Django's DB connection params. + + Same server/credentials as the app database, but a dedicated ``dbos_test`` + database. Overridable via ``DBOS_TEST_SYSTEM_DATABASE_URL``. + """ + override = os.environ.get("DBOS_TEST_SYSTEM_DATABASE_URL") + if override: + return override + db = settings.DATABASES["default"] + host = db.get("HOST") or "localhost" + port = db.get("PORT") or 5432 + user = db.get("USER") or "postgres" + password = db.get("PASSWORD") or "" + return f"postgresql://{user}:{password}@{host}:{port}/dbos_test" + + +def _ensure_database(url: str) -> None: + """Create the target database if it does not already exist.""" + parsed = urlsplit(url) + target = parsed.path.lstrip("/") + admin_url = urlunsplit((parsed.scheme, parsed.netloc, "/postgres", "", "")) + with psycopg.connect(admin_url, autocommit=True) as conn: + exists = conn.execute( + "SELECT 1 FROM pg_database WHERE datname = %s", (target,) + ).fetchone() + if not exists: + conn.execute(f'CREATE DATABASE "{target}"') + + +@pytest.fixture() +def reset_dbos(): + """Give each test a clean DBOS instance on a reset ``dbos_test`` database.""" + url = _dbos_test_system_database_url() + _ensure_database(url) + DBOS.destroy(destroy_registry=False) + config: DBOSConfig = {"name": "pycon-test", "system_database_url": url} + DBOS(config=config) + DBOS.reset_system_database() + DBOS.launch() + yield + DBOS.destroy(destroy_registry=False) + + +def test_healthcheck_executes(reset_dbos): + assert dbos_workflows.healthcheck() == "ok" + + +def test_healthcheck_idempotent(reset_dbos, mocker): + spy = mocker.patch.object(dbos_workflows, "_perform_check", return_value="ok") + + workflow_id = "healthcheck-idempotency-test" + with SetWorkflowID(workflow_id): + first = dbos_workflows.healthcheck() + with SetWorkflowID(workflow_id): + second = dbos_workflows.healthcheck() + + assert first == second == "ok" + # Same workflow ID => DBOS returns the recorded result without re-running. + assert spy.call_count == 1 + + +@override_settings( + DBOS_APP_NAME="pycon", + DBOS_SYSTEM_DATABASE_URL="postgresql://u:p@db:5432/dbos", + DBOS_SYS_DB_POOL_SIZE=5, +) +def test_build_dbos_config(): + config = build_dbos_config() + assert config["name"] == "pycon" + assert config["system_database_url"].startswith("postgresql://") + assert config["sys_db_pool_size"] == 5 + assert config["run_admin_server"] is False From ebd4f4cfa2d406afc5acc0ec3d620636f0cb0953 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 22 Jun 2026 16:43:58 +0200 Subject: [PATCH 3/3] feat(backend): DBOS worker process + enqueue round-trip Phase B of the DBOS foundation (SPEC.md / tasks/plan.md): - manage.py dbos_worker: launches DBOS, registers the shared queue, blocks, and shuts down cleanly on SIGTERM/SIGINT. Mirrors the Celery worker role. - manage.py dbos_enqueue_healthcheck: enqueues the healthcheck workflow via DBOSClient (web-tier pattern) and waits for the result. - dbos_app.py: DBOS_QUEUE_NAME constant shared by producer and consumer. - dbos_workflows.py: pin explicit workflow name for the enqueue contract. - settings: register the `pycon` package as an app so its project-level management commands are discoverable (it has no models). - docker-compose: DBOS_SYSTEM_DATABASE_URL on the shared env + a dbos-worker service. DBOS auto-creates the `dbos` database on first launch locally. Verified end to end: enqueue -> worker executes -> result 'ok'. Additive; Celery/Redis untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/pycon/dbos_app.py | 4 ++ backend/pycon/dbos_workflows.py | 6 +- backend/pycon/management/__init__.py | 0 backend/pycon/management/commands/__init__.py | 0 .../commands/dbos_enqueue_healthcheck.py | 46 +++++++++++++++ .../pycon/management/commands/dbos_worker.py | 58 +++++++++++++++++++ backend/pycon/settings/base.py | 3 + docker-compose.yml | 29 ++++++++++ 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 backend/pycon/management/__init__.py create mode 100644 backend/pycon/management/commands/__init__.py create mode 100644 backend/pycon/management/commands/dbos_enqueue_healthcheck.py create mode 100644 backend/pycon/management/commands/dbos_worker.py diff --git a/backend/pycon/dbos_app.py b/backend/pycon/dbos_app.py index 62889922cd..8602b2eb69 100644 --- a/backend/pycon/dbos_app.py +++ b/backend/pycon/dbos_app.py @@ -13,6 +13,10 @@ from dbos import DBOSClient, DBOSConfig from django.conf import settings +# Name of the database-backed queue the worker dequeues from and the web tier +# enqueues onto. Centralised so producer and consumer cannot drift. +DBOS_QUEUE_NAME = "dbos_default" + def build_dbos_config() -> DBOSConfig: """Build the DBOSConfig from Django settings. diff --git a/backend/pycon/dbos_workflows.py b/backend/pycon/dbos_workflows.py index 2ee07de838..19fd7e9668 100644 --- a/backend/pycon/dbos_workflows.py +++ b/backend/pycon/dbos_workflows.py @@ -10,6 +10,10 @@ from dbos import DBOS +# Explicit, stable workflow name so external enqueuers (DBOSClient) reference it +# by string without depending on the function's location. +HEALTHCHECK_WORKFLOW_NAME = "healthcheck" + def _perform_check() -> str: """The unit of work the healthcheck performs. @@ -27,7 +31,7 @@ def healthcheck_step() -> str: return _perform_check() -@DBOS.workflow() +@DBOS.workflow(name=HEALTHCHECK_WORKFLOW_NAME) def healthcheck() -> str: """Run one step and return its result.""" result = healthcheck_step() diff --git a/backend/pycon/management/__init__.py b/backend/pycon/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pycon/management/commands/__init__.py b/backend/pycon/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/pycon/management/commands/dbos_enqueue_healthcheck.py b/backend/pycon/management/commands/dbos_enqueue_healthcheck.py new file mode 100644 index 0000000000..54996d0ded --- /dev/null +++ b/backend/pycon/management/commands/dbos_enqueue_healthcheck.py @@ -0,0 +1,46 @@ +"""Enqueue the proof-of-concept ``healthcheck`` workflow via ``DBOSClient``. + +Demonstrates how the web tier submits work to DBOS without launching it: build a +client against the system database and enqueue by workflow name. With the +``dbos_worker`` process running, the workflow is dequeued, executed, and its +result returned here — proving the full round-trip. See SPEC.md (Phase B). +""" + +from django.core.management.base import BaseCommand, CommandError + +from dbos import DBOSClient, EnqueueOptions + +from pycon.dbos_app import DBOS_QUEUE_NAME +from pycon.dbos_workflows import HEALTHCHECK_WORKFLOW_NAME + + +class Command(BaseCommand): + help = "Enqueue the healthcheck workflow via DBOSClient (proves the round-trip)." + + def add_arguments(self, parser): + parser.add_argument( + "--workflow-id", + help="Optional explicit workflow ID (re-using one is idempotent).", + ) + + def handle(self, *args, **options): + from django.conf import settings + + if not settings.DBOS_SYSTEM_DATABASE_URL: + raise CommandError("DBOS_SYSTEM_DATABASE_URL is not set.") + + client = DBOSClient(system_database_url=settings.DBOS_SYSTEM_DATABASE_URL) + try: + enqueue_options: EnqueueOptions = { + "workflow_name": HEALTHCHECK_WORKFLOW_NAME, + "queue_name": DBOS_QUEUE_NAME, + } + if options.get("workflow_id"): + enqueue_options["workflow_id"] = options["workflow_id"] + + handle = client.enqueue(enqueue_options) + self.stdout.write(f"Enqueued workflow id={handle.workflow_id}") + result = handle.get_result() + self.stdout.write(self.style.SUCCESS(f"Workflow completed -> {result!r}")) + finally: + client.destroy() diff --git a/backend/pycon/management/commands/dbos_worker.py b/backend/pycon/management/commands/dbos_worker.py new file mode 100644 index 0000000000..a586576229 --- /dev/null +++ b/backend/pycon/management/commands/dbos_worker.py @@ -0,0 +1,58 @@ +"""Run the dedicated DBOS worker process. + +This launches DBOS and blocks, executing durable workflows. It plays the same +role for DBOS that the Celery worker plays for Celery, and runs alongside it. +The web tier never launches DBOS — it only enqueues via ``get_dbos_client()``. + +See SPEC.md / tasks/plan.md (Phase B). +""" + +import signal +import threading + +from django.core.management.base import BaseCommand, CommandError + +from dbos import DBOS + +# Importing the workflow module registers its @DBOS.workflow / @DBOS.step +# functions before launch so the worker can execute them. +from pycon import dbos_workflows # noqa: F401 +from pycon.dbos_app import DBOS_QUEUE_NAME, build_dbos_config + + +class Command(BaseCommand): + help = "Launch the DBOS worker (executes durable workflows). Runs alongside Celery." + + def handle(self, *args, **options): + config = build_dbos_config() + if not config.get("system_database_url"): + raise CommandError( + "DBOS_SYSTEM_DATABASE_URL is not set; cannot launch the DBOS worker." + ) + + DBOS(config=config) + DBOS.launch() + # Register the shared queue (after launch) so this worker dequeues the + # workflows the web tier enqueues onto it. + DBOS.register_queue(DBOS_QUEUE_NAME) + self.stdout.write( + self.style.SUCCESS( + f"DBOS launched (app={config['name']}, queue={DBOS_QUEUE_NAME}). " + "Waiting for work — Ctrl-C to stop." + ) + ) + + stop = threading.Event() + + def _shutdown(signum, frame): + self.stdout.write("Received shutdown signal, stopping DBOS...") + stop.set() + + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + + try: + stop.wait() + finally: + DBOS.destroy(workflow_completion_timeout_sec=30) + self.stdout.write("DBOS stopped.") diff --git a/backend/pycon/settings/base.py b/backend/pycon/settings/base.py index 8bc09ac482..100a87c079 100644 --- a/backend/pycon/settings/base.py +++ b/backend/pycon/settings/base.py @@ -128,6 +128,9 @@ "billing.apps.BillingConfig", "privacy_policy.apps.PrivacyPolicyConfig", "visa.apps.VisaConfig", + # Project package, registered as an app so its project-level management + # commands (e.g. dbos_worker) are discoverable. It has no models. + "pycon", ] MIDDLEWARE = [ diff --git a/docker-compose.yml b/docker-compose.yml index cbeceff83f..1602615c71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,9 @@ x-defaults: MEDIA_FILES_PRIVATE_STORAGE_BACKEND: ${MEDIA_FILES_PRIVATE_STORAGE_BACKEND:-pycon.storages.CustomFileSystemStorage} CELERY_BROKER_URL: redis://redis:6379/9 CELERY_RESULT_BACKEND: redis://redis:6379/10 + # DBOS durable-workflow system DB: separate `dbos` database on the same + # Postgres server. Uses the SQLAlchemy postgresql:// scheme (not psql://). + DBOS_SYSTEM_DATABASE_URL: postgresql://pycon:pycon@backend-db:5432/dbos CLAMAV_HOST: clamav CLAMAV_PORT: 3310 @@ -62,6 +65,32 @@ services: timeout: 10s retries: 10 + dbos-worker: + build: + context: ./backend + dockerfile: ../Dockerfile.python.local + networks: [pycon_net] + restart: unless-stopped + # Launches DBOS and executes durable workflows (alongside Celery). Depends on + # the backend having synced deps + run migrations. DBOS auto-creates the + # `dbos` database on first launch (the local pycon role is a superuser). + command: sh -c "export DJANGO_SETTINGS_MODULE=pycon.settings.dev && + uv run python manage.py dbos_worker" + depends_on: + backend-db: + condition: service_healthy + backend: + condition: service_healthy + tty: true + stdin_open: true + volumes: + - ./backend:/home/app/ + - /.venv/ + environment: + <<: *pycon_backend_envs + env_file: + - .env + custom-admin: build: context: ./backend