tina4-python 3.13.40__tar.gz → 3.13.42__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.
- {tina4_python-3.13.40 → tina4_python-3.13.42}/PKG-INFO +1 -1
- {tina4_python-3.13.40 → tina4_python-3.13.42}/pyproject.toml +1 -1
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/CLAUDE.md +45 -1
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/__init__.py +38 -8
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/lite_backend.py +132 -21
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/mongo_backend.py +8 -2
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/mongo_backend.py +71 -3
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/swagger/__init__.py +260 -12
- {tina4_python-3.13.40 → tina4_python-3.13.42}/.gitignore +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/README.md +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/env.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1145,9 +1145,14 @@ for job in queue.consume():
|
|
|
1145
1145
|
```python
|
|
1146
1146
|
queue = Queue(topic="tasks", max_retries=3)
|
|
1147
1147
|
|
|
1148
|
+
# Reservation / visibility timeout (seconds): a popped job is reserved this long;
|
|
1149
|
+
# if the consumer dies before complete()/fail() the next pop() reclaims it
|
|
1150
|
+
# (at-least-once delivery). Default 300; env TINA4_QUEUE_VISIBILITY_TIMEOUT; <= 0 disables.
|
|
1151
|
+
queue = Queue(topic="tasks", visibility_timeout=300)
|
|
1152
|
+
|
|
1148
1153
|
# Check queue size
|
|
1149
1154
|
queue.size() # pending jobs
|
|
1150
|
-
queue.size("reserved") # currently
|
|
1155
|
+
queue.size("reserved") # currently reserved (in-flight) jobs
|
|
1151
1156
|
|
|
1152
1157
|
# Retry failed jobs (under max_retries limit)
|
|
1153
1158
|
queue.retry_failed()
|
|
@@ -1159,6 +1164,14 @@ dead = queue.dead_letters()
|
|
|
1159
1164
|
queue.purge("completed")
|
|
1160
1165
|
```
|
|
1161
1166
|
|
|
1167
|
+
**At-least-once delivery (file + MongoDB backends).** A popped job is held as a
|
|
1168
|
+
reservation for `visibility_timeout` seconds. If the consumer crashes, OOMs, or
|
|
1169
|
+
is evicted before calling `job.complete()` / `job.fail()`, the next `pop()`
|
|
1170
|
+
reclaims the abandoned reservation: it increments `attempts` and re-enqueues the
|
|
1171
|
+
job, or dead-letters it once it has hit `max_retries`. A dead consumer therefore
|
|
1172
|
+
never strands a job. RabbitMQ and Kafka delegate redelivery to the broker (the
|
|
1173
|
+
framework timeout does not apply there).
|
|
1174
|
+
|
|
1162
1175
|
### When to use queues
|
|
1163
1176
|
- Sending emails or SMS
|
|
1164
1177
|
- Generating PDFs/reports
|
|
@@ -1644,6 +1657,37 @@ TINA4_SWAGGER_LICENSE= # SPDX license name (e.g. MIT) for info.licens
|
|
|
1644
1657
|
TINA4_SWAGGER_SERVERS= # comma-separated server URLs for the OpenAPI servers[] block; falls back to SWAGGER_DEV_URL
|
|
1645
1658
|
TINA4_SWAGGER_UI_CDN= # base URL for the Swagger UI assets (default jsdelivr); point at a self-hosted mirror for air-gapped use
|
|
1646
1659
|
SWAGGER_DEV_URL=http://localhost:7145 # single dev-server URL (used when TINA4_SWAGGER_SERVERS is unset)
|
|
1660
|
+
TINA4_SWAGGER_OPENAPI=3.0.3 # OpenAPI version: 3.0.3 (default) or 3.1 (-> emits 3.1.0)
|
|
1661
|
+
TINA4_SWAGGER_BEARER_FORMAT=JWT # bearerFormat on the built-in bearerAuth scheme (e.g. opaque for sk_live_ keys)
|
|
1662
|
+
TINA4_SWAGGER_API_KEY_NAME= # if set, emit an apiKeyAuth scheme with this header/query name (e.g. X-Api-Key)
|
|
1663
|
+
TINA4_SWAGGER_API_KEY_IN=header # where the apiKey lives: header (default) | query | cookie
|
|
1664
|
+
TINA4_SWAGGER_DEFAULT_SCHEME=bearerAuth # scheme secured routes use when no @security is set
|
|
1665
|
+
TINA4_SWAGGER_INCLUDE= # comma-separated path prefixes to include (allow-list; only these documented)
|
|
1666
|
+
TINA4_SWAGGER_EXCLUDE= # comma-separated path prefixes to drop (/swagger + /__dev are always excluded)
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
**Per-route security + reusable schemas (v3.13.42).** Configure named security
|
|
1670
|
+
schemes (configurable `bearerFormat`, an optional `apiKey` scheme, or register
|
|
1671
|
+
arbitrary schemes incl. `oauth2` with scopes via `Swagger.add_security_scheme(name, def)`),
|
|
1672
|
+
then declare them per route with `@security`:
|
|
1673
|
+
|
|
1674
|
+
```python
|
|
1675
|
+
from tina4_python.swagger import security, request_schema, response_schema, Swagger
|
|
1676
|
+
|
|
1677
|
+
@security("oauth2", scopes=["read:users"]) # scopes kept only for oauth2/openIdConnect
|
|
1678
|
+
@get("/api/v1/users")
|
|
1679
|
+
async def list_users(request, response): ...
|
|
1680
|
+
|
|
1681
|
+
@security("public") # explicitly public (overrides write-secure-by-default)
|
|
1682
|
+
@post("/api/v1/webhook")
|
|
1683
|
+
async def webhook(request, response): ...
|
|
1684
|
+
|
|
1685
|
+
# Reusable component schemas referenced by $ref (beyond ORM-model auto-schemas):
|
|
1686
|
+
Swagger.add_schema("CreateUser", {"type": "object", "properties": {"email": {"type": "string"}}})
|
|
1687
|
+
@request_schema("CreateUser")
|
|
1688
|
+
@response_schema("User", status=200)
|
|
1689
|
+
@post("/api/v1/users")
|
|
1690
|
+
async def create_user(request, response): ...
|
|
1647
1691
|
```
|
|
1648
1692
|
|
|
1649
1693
|
The spec is OpenAPI 3.0.3. ORM models registered via AutoCrud become reusable
|
|
@@ -17,6 +17,10 @@ Environment variables:
|
|
|
17
17
|
TINA4_QUEUE_BACKEND — 'file' (default), 'rabbitmq', 'kafka', or 'mongodb'
|
|
18
18
|
TINA4_QUEUE_URL — connection URL for rabbitmq/kafka
|
|
19
19
|
TINA4_QUEUE_PATH — file backend storage path (default: data/queue)
|
|
20
|
+
TINA4_QUEUE_VISIBILITY_TIMEOUT — seconds a popped job stays reserved before
|
|
21
|
+
a dead consumer's job is reclaimed (default 300;
|
|
22
|
+
<= 0 disables reclaim). File + MongoDB backends only;
|
|
23
|
+
RabbitMQ/Kafka delegate visibility to the broker.
|
|
20
24
|
TINA4_RABBITMQ_HOST — RabbitMQ host (default: localhost)
|
|
21
25
|
TINA4_KAFKA_BROKERS — Kafka brokers (default: localhost:9092)
|
|
22
26
|
TINA4_MONGO_HOST — MongoDB host (default: localhost)
|
|
@@ -24,7 +28,6 @@ Environment variables:
|
|
|
24
28
|
import json
|
|
25
29
|
import os
|
|
26
30
|
import time
|
|
27
|
-
import threading
|
|
28
31
|
from datetime import datetime, timezone
|
|
29
32
|
|
|
30
33
|
from tina4_python.queue.job import Job
|
|
@@ -34,19 +37,31 @@ from tina4_python.queue.kafka_backend import KafkaBackend
|
|
|
34
37
|
from tina4_python.queue.mongo_backend import MongoBackend
|
|
35
38
|
|
|
36
39
|
|
|
37
|
-
def
|
|
40
|
+
def _default_visibility_timeout() -> float:
|
|
41
|
+
"""Reservation/visibility timeout in seconds, from env (default 300 = 5 min)."""
|
|
42
|
+
try:
|
|
43
|
+
return float(os.environ.get("TINA4_QUEUE_VISIBILITY_TIMEOUT", "300"))
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return 300.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_backend(topic: str, backend: str | None, max_retries: int,
|
|
49
|
+
retry_backoff: int = 0, visibility_timeout: float = 300.0):
|
|
38
50
|
"""Resolve which backend adapter to use."""
|
|
39
51
|
chosen = backend or os.environ.get("TINA4_QUEUE_BACKEND", "file")
|
|
40
52
|
chosen = chosen.lower().strip()
|
|
41
53
|
|
|
42
54
|
if chosen in ("file", "default", "lite"):
|
|
43
|
-
return LiteBackend(topic, max_retries, retry_backoff)
|
|
55
|
+
return LiteBackend(topic, max_retries, retry_backoff, visibility_timeout)
|
|
44
56
|
elif chosen == "rabbitmq":
|
|
57
|
+
# Broker manages visibility/redelivery (unacked messages requeue on
|
|
58
|
+
# channel close) — the framework timeout is accepted but not used.
|
|
45
59
|
return RabbitMQBackend(topic, max_retries)
|
|
46
60
|
elif chosen == "kafka":
|
|
61
|
+
# Consumer-group offsets manage redelivery — framework timeout N/A.
|
|
47
62
|
return KafkaBackend(topic, max_retries)
|
|
48
63
|
elif chosen in ("mongodb", "mongo"):
|
|
49
|
-
return MongoBackend(topic, max_retries)
|
|
64
|
+
return MongoBackend(topic, max_retries, visibility_timeout)
|
|
50
65
|
else:
|
|
51
66
|
raise ValueError(f"Unknown queue backend: {chosen!r}. Use 'file', 'rabbitmq', 'kafka', or 'mongodb'.")
|
|
52
67
|
|
|
@@ -63,13 +78,23 @@ class Queue:
|
|
|
63
78
|
"""
|
|
64
79
|
|
|
65
80
|
def __init__(self, topic: str = "default", max_retries: int = 3,
|
|
66
|
-
backend: str | None = None, retry_backoff: int = 0
|
|
81
|
+
backend: str | None = None, retry_backoff: int = 0,
|
|
82
|
+
visibility_timeout: float | None = None):
|
|
67
83
|
self.topic = topic
|
|
68
84
|
self.max_retries = max_retries
|
|
69
85
|
# Seconds to wait before a failed job is re-attempted (file backend).
|
|
70
86
|
# Default 0 = retry on the very next pop()/consume() iteration.
|
|
71
87
|
self.retry_backoff = retry_backoff
|
|
72
|
-
|
|
88
|
+
# Reservation/visibility timeout (seconds). A popped job is reserved for
|
|
89
|
+
# this long; if the consumer dies before complete()/fail() the next
|
|
90
|
+
# pop() reclaims it (at-least-once delivery). Falls back to
|
|
91
|
+
# TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300 (5 min). <= 0 disables reclaim.
|
|
92
|
+
self.visibility_timeout = (
|
|
93
|
+
visibility_timeout if visibility_timeout is not None
|
|
94
|
+
else _default_visibility_timeout()
|
|
95
|
+
)
|
|
96
|
+
self._backend = _resolve_backend(topic, backend, max_retries, retry_backoff,
|
|
97
|
+
self.visibility_timeout)
|
|
73
98
|
|
|
74
99
|
def push(self, data: dict, priority: int = 0, delay_seconds: int = 0):
|
|
75
100
|
"""Add a job to the queue. Returns job ID."""
|
|
@@ -211,12 +236,14 @@ class Queue:
|
|
|
211
236
|
|
|
212
237
|
old_topic = self.topic
|
|
213
238
|
self.topic = topic
|
|
214
|
-
self._backend = _resolve_backend(topic, None, self.max_retries, self.retry_backoff
|
|
239
|
+
self._backend = _resolve_backend(topic, None, self.max_retries, self.retry_backoff,
|
|
240
|
+
self.visibility_timeout)
|
|
215
241
|
try:
|
|
216
242
|
return self.push(data, priority, delay_seconds)
|
|
217
243
|
finally:
|
|
218
244
|
self.topic = old_topic
|
|
219
|
-
self._backend = _resolve_backend(old_topic, None, self.max_retries, self.retry_backoff
|
|
245
|
+
self._backend = _resolve_backend(old_topic, None, self.max_retries, self.retry_backoff,
|
|
246
|
+
self.visibility_timeout)
|
|
220
247
|
|
|
221
248
|
def consume(self, topic: str = None, job_id: str = None, poll_interval: float = 1.0,
|
|
222
249
|
iterations: int = 0, batch_size: int = 1):
|
|
@@ -305,6 +332,9 @@ class Queue:
|
|
|
305
332
|
with open(filepath) as f:
|
|
306
333
|
job_data = json.load(f)
|
|
307
334
|
if job_data.get("id") == job_id and job_data.get("status") == "pending":
|
|
335
|
+
# Reserve (so a dead consumer's job is reclaimable) then
|
|
336
|
+
# claim the pending file — mirrors LiteBackend.pop().
|
|
337
|
+
self._backend._write_reserved(job_data)
|
|
308
338
|
os.unlink(filepath)
|
|
309
339
|
return Job(
|
|
310
340
|
queue=self, job_id=job_data["id"],
|
|
@@ -30,13 +30,22 @@ class LiteBackend:
|
|
|
30
30
|
Each job is stored as a separate .queue-data JSON file.
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
def __init__(self, topic: str, max_retries: int, retry_backoff: int = 0
|
|
33
|
+
def __init__(self, topic: str, max_retries: int, retry_backoff: int = 0,
|
|
34
|
+
visibility_timeout: float = 300.0):
|
|
34
35
|
self._topic = topic
|
|
35
36
|
self._max_retries = max_retries
|
|
36
37
|
# Seconds to delay a job's next attempt when it is automatically
|
|
37
38
|
# re-enqueued by fail(). 0 (the default) means retry immediately —
|
|
38
39
|
# the next pop()/consume() iteration picks it up straight away.
|
|
39
40
|
self._retry_backoff = retry_backoff
|
|
41
|
+
# Reservation/visibility timeout (seconds). When a job is popped it is
|
|
42
|
+
# held in reserved/ with available_at = now + visibility_timeout. If the
|
|
43
|
+
# consumer dies before complete()/fail() (crash, OOM, k8s eviction), the
|
|
44
|
+
# next pop() reclaims it once the window expires — incrementing attempts
|
|
45
|
+
# and re-enqueuing, or dead-lettering past max_retries. <= 0 disables the
|
|
46
|
+
# reclaim (a reservation then lasts until the consumer acks — the old
|
|
47
|
+
# at-most-once behaviour).
|
|
48
|
+
self._visibility_timeout = visibility_timeout
|
|
40
49
|
self._base_path = os.environ.get("TINA4_QUEUE_PATH", "data/queue")
|
|
41
50
|
self._lock = threading.Lock()
|
|
42
51
|
self._seq = 0
|
|
@@ -47,6 +56,7 @@ class LiteBackend:
|
|
|
47
56
|
failed_dir = os.path.join(queue_dir, "failed")
|
|
48
57
|
os.makedirs(queue_dir, exist_ok=True)
|
|
49
58
|
os.makedirs(failed_dir, exist_ok=True)
|
|
59
|
+
os.makedirs(self._reserved_dir(), exist_ok=True)
|
|
50
60
|
|
|
51
61
|
def _queue_dir(self) -> str:
|
|
52
62
|
return os.path.join(self._base_path, self._topic)
|
|
@@ -54,6 +64,12 @@ class LiteBackend:
|
|
|
54
64
|
def _failed_dir(self) -> str:
|
|
55
65
|
return os.path.join(self._base_path, self._topic, "failed")
|
|
56
66
|
|
|
67
|
+
def _reserved_dir(self) -> str:
|
|
68
|
+
return os.path.join(self._base_path, self._topic, "reserved")
|
|
69
|
+
|
|
70
|
+
def _reserved_path(self, job_id: str) -> str:
|
|
71
|
+
return os.path.join(self._reserved_dir(), f"{job_id}.queue-data")
|
|
72
|
+
|
|
57
73
|
def _next_prefix(self) -> str:
|
|
58
74
|
self._seq += 1
|
|
59
75
|
return f"{int(time.time() * 1000)}-{self._seq:06d}"
|
|
@@ -119,14 +135,92 @@ class LiteBackend:
|
|
|
119
135
|
)
|
|
120
136
|
return candidates
|
|
121
137
|
|
|
122
|
-
def
|
|
138
|
+
def _write_reserved(self, job_data: dict, attempts: int | None = None):
|
|
139
|
+
"""Persist a reservation record so a dead consumer's job is reclaimable.
|
|
140
|
+
|
|
141
|
+
Stores reserved_at + available_at = now + visibility_timeout. The next
|
|
142
|
+
pop() reclaims this job once available_at has passed (see
|
|
143
|
+
_reclaim_expired). complete()/fail()/retry() delete the record.
|
|
144
|
+
"""
|
|
123
145
|
now = _now()
|
|
146
|
+
vt = self._visibility_timeout or 0
|
|
147
|
+
record = {
|
|
148
|
+
"id": job_data["id"],
|
|
149
|
+
"topic": job_data.get("topic", self._topic),
|
|
150
|
+
"data": job_data.get("data", {}),
|
|
151
|
+
"status": "reserved",
|
|
152
|
+
"priority": job_data.get("priority", 0),
|
|
153
|
+
"attempts": job_data.get("attempts", 0) if attempts is None else attempts,
|
|
154
|
+
"error": job_data.get("error"),
|
|
155
|
+
"reserved_at": now,
|
|
156
|
+
"available_at": _future(vt) if vt > 0 else now,
|
|
157
|
+
"created_at": job_data.get("created_at", now),
|
|
158
|
+
}
|
|
159
|
+
os.makedirs(self._reserved_dir(), exist_ok=True)
|
|
160
|
+
with open(self._reserved_path(record["id"]), "w") as f:
|
|
161
|
+
json.dump(record, f, indent=2, default=str)
|
|
162
|
+
|
|
163
|
+
def _reclaim_expired(self, now: str):
|
|
164
|
+
"""Return expired reservations to the queue (at-least-once delivery).
|
|
165
|
+
|
|
166
|
+
A reserved job whose ``available_at <= now`` means its consumer never
|
|
167
|
+
acknowledged in time (crash / OOM / pod eviction). Increment attempts
|
|
168
|
+
and either re-enqueue it (so the next pop picks it up) or dead-letter it
|
|
169
|
+
once it has hit max_retries. Disabled when visibility_timeout <= 0.
|
|
170
|
+
"""
|
|
171
|
+
if not self._visibility_timeout or self._visibility_timeout <= 0:
|
|
172
|
+
return
|
|
173
|
+
reserved_dir = self._reserved_dir()
|
|
174
|
+
try:
|
|
175
|
+
filenames = os.listdir(reserved_dir)
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
return
|
|
178
|
+
for filename in filenames:
|
|
179
|
+
if not filename.endswith(".queue-data"):
|
|
180
|
+
continue
|
|
181
|
+
filepath = os.path.join(reserved_dir, filename)
|
|
182
|
+
try:
|
|
183
|
+
with open(filepath) as f:
|
|
184
|
+
job_data = json.load(f)
|
|
185
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
186
|
+
continue
|
|
187
|
+
if job_data.get("available_at", "") > now:
|
|
188
|
+
continue # reservation still valid
|
|
189
|
+
# Atomically claim the expired reservation by deleting its file.
|
|
190
|
+
try:
|
|
191
|
+
os.unlink(filepath)
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
continue # another worker reclaimed it first
|
|
194
|
+
attempts = job_data.get("attempts", 0) + 1
|
|
195
|
+
error = ("reservation timed out — consumer did not acknowledge "
|
|
196
|
+
"within the visibility timeout")
|
|
197
|
+
job = Job(
|
|
198
|
+
queue=None,
|
|
199
|
+
job_id=job_data["id"],
|
|
200
|
+
topic=job_data.get("topic", self._topic),
|
|
201
|
+
data=job_data.get("data", {}),
|
|
202
|
+
priority=job_data.get("priority", 0),
|
|
203
|
+
attempts=attempts,
|
|
204
|
+
error=error,
|
|
205
|
+
)
|
|
206
|
+
if attempts >= self._max_retries:
|
|
207
|
+
self._dead_letter(job, error)
|
|
208
|
+
else:
|
|
209
|
+
self._requeue(job, delay_seconds=0, error=error)
|
|
210
|
+
|
|
211
|
+
def pop(self, queue_ref) -> Job | None:
|
|
124
212
|
queue_dir = self._queue_dir()
|
|
125
213
|
|
|
126
214
|
with self._lock:
|
|
215
|
+
# First return any reservations whose consumer died mid-flight.
|
|
216
|
+
self._reclaim_expired(_now())
|
|
217
|
+
now = _now()
|
|
127
218
|
for filename, job_data in self._available_candidates(now):
|
|
128
219
|
filepath = os.path.join(queue_dir, filename)
|
|
129
|
-
#
|
|
220
|
+
# Write the reservation BEFORE claiming the pending file, so a
|
|
221
|
+
# crash between claim and reserve can never strand the job.
|
|
222
|
+
# Only the worker that wins the unlink owns — and returns — it.
|
|
223
|
+
self._write_reserved(job_data)
|
|
130
224
|
try:
|
|
131
225
|
os.unlink(filepath)
|
|
132
226
|
except FileNotFoundError:
|
|
@@ -149,15 +243,17 @@ class LiteBackend:
|
|
|
149
243
|
|
|
150
244
|
Returns a partial batch if fewer than ``count`` are available.
|
|
151
245
|
"""
|
|
152
|
-
now = _now()
|
|
153
246
|
queue_dir = self._queue_dir()
|
|
154
247
|
results = []
|
|
155
248
|
|
|
156
249
|
with self._lock:
|
|
250
|
+
self._reclaim_expired(_now())
|
|
251
|
+
now = _now()
|
|
157
252
|
for filename, job_data in self._available_candidates(now):
|
|
158
253
|
if len(results) >= count:
|
|
159
254
|
break
|
|
160
255
|
filepath = os.path.join(queue_dir, filename)
|
|
256
|
+
self._write_reserved(job_data)
|
|
161
257
|
try:
|
|
162
258
|
os.unlink(filepath)
|
|
163
259
|
except FileNotFoundError:
|
|
@@ -180,7 +276,12 @@ class LiteBackend:
|
|
|
180
276
|
_DEAD_STATES = ("failed", "dead", "dead_letter")
|
|
181
277
|
|
|
182
278
|
def size(self, status: str = "pending") -> int:
|
|
183
|
-
|
|
279
|
+
if status == "reserved":
|
|
280
|
+
scan_dir = self._reserved_dir()
|
|
281
|
+
elif status in self._DEAD_STATES:
|
|
282
|
+
scan_dir = self._failed_dir()
|
|
283
|
+
else:
|
|
284
|
+
scan_dir = self._queue_dir()
|
|
184
285
|
count = 0
|
|
185
286
|
try:
|
|
186
287
|
for filename in os.listdir(scan_dir):
|
|
@@ -192,9 +293,9 @@ class LiteBackend:
|
|
|
192
293
|
job_data = json.load(f)
|
|
193
294
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
194
295
|
continue
|
|
195
|
-
if status in self._DEAD_STATES:
|
|
196
|
-
# Every file in failed/
|
|
197
|
-
#
|
|
296
|
+
if status in self._DEAD_STATES or status == "reserved":
|
|
297
|
+
# Every file in the failed/ (or reserved/) dir matches the
|
|
298
|
+
# requested status; count them all.
|
|
198
299
|
count += 1
|
|
199
300
|
elif job_data.get("status") == status:
|
|
200
301
|
count += 1
|
|
@@ -332,25 +433,32 @@ class LiteBackend:
|
|
|
332
433
|
return False
|
|
333
434
|
|
|
334
435
|
def clear(self) -> int:
|
|
335
|
-
queue_dir = self._queue_dir()
|
|
336
436
|
count = 0
|
|
437
|
+
for scan_dir in (self._queue_dir(), self._reserved_dir()):
|
|
438
|
+
try:
|
|
439
|
+
for filename in os.listdir(scan_dir):
|
|
440
|
+
if not filename.endswith(".queue-data"):
|
|
441
|
+
continue
|
|
442
|
+
try:
|
|
443
|
+
os.unlink(os.path.join(scan_dir, filename))
|
|
444
|
+
count += 1
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
continue
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
pass
|
|
449
|
+
return count
|
|
450
|
+
|
|
451
|
+
def _clear_reservation(self, job_id: str):
|
|
452
|
+
"""Delete a job's reservation record (best-effort)."""
|
|
337
453
|
try:
|
|
338
|
-
|
|
339
|
-
if not filename.endswith(".queue-data"):
|
|
340
|
-
continue
|
|
341
|
-
try:
|
|
342
|
-
os.unlink(os.path.join(queue_dir, filename))
|
|
343
|
-
count += 1
|
|
344
|
-
except FileNotFoundError:
|
|
345
|
-
continue
|
|
454
|
+
os.unlink(self._reserved_path(job_id))
|
|
346
455
|
except FileNotFoundError:
|
|
347
456
|
pass
|
|
348
|
-
return count
|
|
349
457
|
|
|
350
458
|
def complete(self, job: Job):
|
|
351
|
-
#
|
|
352
|
-
# terminal
|
|
353
|
-
|
|
459
|
+
# The pending file was claimed on pop and a reservation record written;
|
|
460
|
+
# complete() is terminal, so drop the reservation. The job is done.
|
|
461
|
+
self._clear_reservation(job.id)
|
|
354
462
|
|
|
355
463
|
def _requeue(self, job: Job, delay_seconds: int = 0, error: str | None = None):
|
|
356
464
|
"""Write the job back to the pending queue (queue dir).
|
|
@@ -405,6 +513,8 @@ class LiteBackend:
|
|
|
405
513
|
attempted ``max_retries`` times (``attempts >= max_retries``) it is
|
|
406
514
|
moved to the dead-letter store, where dead_letters() returns it.
|
|
407
515
|
"""
|
|
516
|
+
# Clear the reservation — the consumer acknowledged (with a failure).
|
|
517
|
+
self._clear_reservation(job.id)
|
|
408
518
|
job.attempts += 1
|
|
409
519
|
job.error = error
|
|
410
520
|
if job.attempts < self._max_retries:
|
|
@@ -418,5 +528,6 @@ class LiteBackend:
|
|
|
418
528
|
Always re-enqueues regardless of the retry limit — this is a manual
|
|
419
529
|
override, distinct from the automatic fail() path.
|
|
420
530
|
"""
|
|
531
|
+
self._clear_reservation(job.id)
|
|
421
532
|
job.attempts += 1
|
|
422
533
|
self._requeue(job, delay_seconds=delay_seconds, error=None)
|
|
@@ -23,11 +23,11 @@ def _future(seconds: int) -> str:
|
|
|
23
23
|
class MongoBackend:
|
|
24
24
|
"""Backend adapter wrapping MongoBackend for the unified Queue API."""
|
|
25
25
|
|
|
26
|
-
def __init__(self, topic: str, max_retries: int):
|
|
26
|
+
def __init__(self, topic: str, max_retries: int, visibility_timeout: float = 300.0):
|
|
27
27
|
from tina4_python.queue_backends import MongoConnector as _MongoBackend
|
|
28
28
|
|
|
29
29
|
url = os.environ.get("TINA4_QUEUE_URL", "")
|
|
30
|
-
config = {}
|
|
30
|
+
config = {"visibility_timeout": visibility_timeout}
|
|
31
31
|
if url:
|
|
32
32
|
config["uri"] = url
|
|
33
33
|
self._backend = _MongoBackend(**config)
|
|
@@ -39,6 +39,12 @@ class MongoBackend:
|
|
|
39
39
|
return self._backend.enqueue(self._topic, msg)
|
|
40
40
|
|
|
41
41
|
def pop(self, queue_ref) -> Job | None:
|
|
42
|
+
# Reclaim any reservations whose consumer died before acking, then take
|
|
43
|
+
# the next available message (at-least-once delivery).
|
|
44
|
+
try:
|
|
45
|
+
self._backend.reclaim_expired(self._topic, self._max_retries)
|
|
46
|
+
except AttributeError:
|
|
47
|
+
pass # older connector without reclaim support
|
|
42
48
|
result = self._backend.dequeue(self._topic)
|
|
43
49
|
if result is None:
|
|
44
50
|
return None
|
|
@@ -25,7 +25,6 @@ Document schema:
|
|
|
25
25
|
completed_at: str | None,
|
|
26
26
|
}
|
|
27
27
|
"""
|
|
28
|
-
import json
|
|
29
28
|
import os
|
|
30
29
|
import uuid
|
|
31
30
|
from datetime import datetime, timezone
|
|
@@ -47,6 +46,16 @@ class MongoConnector:
|
|
|
47
46
|
self._collection_name = config.get(
|
|
48
47
|
"collection", os.environ.get("TINA4_MONGO_COLLECTION", "tina4_queue")
|
|
49
48
|
)
|
|
49
|
+
# Reservation/visibility timeout (seconds): a dequeued message is held
|
|
50
|
+
# reserved with available_at = now + timeout; reclaim_expired() returns
|
|
51
|
+
# it once that passes (consumer died mid-flight). <= 0 disables reclaim.
|
|
52
|
+
try:
|
|
53
|
+
self._visibility_timeout = float(config.get(
|
|
54
|
+
"visibility_timeout",
|
|
55
|
+
os.environ.get("TINA4_QUEUE_VISIBILITY_TIMEOUT", "300"),
|
|
56
|
+
))
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
self._visibility_timeout = 300.0
|
|
50
59
|
|
|
51
60
|
self._pymongo = None
|
|
52
61
|
self._client = None
|
|
@@ -105,7 +114,13 @@ class MongoConnector:
|
|
|
105
114
|
return msg_id
|
|
106
115
|
|
|
107
116
|
def dequeue(self, topic: str) -> dict | None:
|
|
108
|
-
"""Atomically claim the next available message. Returns message dict or None.
|
|
117
|
+
"""Atomically claim the next available message. Returns message dict or None.
|
|
118
|
+
|
|
119
|
+
The claim advances ``available_at`` to ``now + visibility_timeout`` and
|
|
120
|
+
records ``reserved_at`` so reclaim_expired() can return the job if the
|
|
121
|
+
consumer dies before acknowledge()/reject(). This is the fix for the
|
|
122
|
+
"reserved forever" bug — previously available_at was left unchanged.
|
|
123
|
+
"""
|
|
109
124
|
self._ensure_connected()
|
|
110
125
|
now = _now()
|
|
111
126
|
|
|
@@ -115,7 +130,11 @@ class MongoConnector:
|
|
|
115
130
|
"status": "pending",
|
|
116
131
|
"available_at": {"$lte": now},
|
|
117
132
|
},
|
|
118
|
-
{"$set": {
|
|
133
|
+
{"$set": {
|
|
134
|
+
"status": "reserved",
|
|
135
|
+
"reserved_at": now,
|
|
136
|
+
"available_at": _future(self._visibility_timeout),
|
|
137
|
+
}},
|
|
119
138
|
sort=[("priority", self._pymongo.DESCENDING), ("created_at", self._pymongo.ASCENDING)],
|
|
120
139
|
return_document=self._pymongo.ReturnDocument.AFTER,
|
|
121
140
|
)
|
|
@@ -126,6 +145,50 @@ class MongoConnector:
|
|
|
126
145
|
result["id"] = doc["_id"]
|
|
127
146
|
return result
|
|
128
147
|
|
|
148
|
+
def reclaim_expired(self, topic: str, max_retries: int) -> int:
|
|
149
|
+
"""Return reservations whose visibility window expired (at-least-once).
|
|
150
|
+
|
|
151
|
+
A message left ``reserved`` with ``available_at <= now`` had a consumer
|
|
152
|
+
die before acknowledging. Each is atomically flipped back to ``pending``
|
|
153
|
+
with ``attempts`` incremented (so the next dequeue re-delivers it); once
|
|
154
|
+
``attempts >= max_retries`` it is dead-lettered instead. Returns the
|
|
155
|
+
number reclaimed. Disabled when visibility_timeout <= 0.
|
|
156
|
+
"""
|
|
157
|
+
if not self._visibility_timeout or self._visibility_timeout <= 0:
|
|
158
|
+
return 0
|
|
159
|
+
self._ensure_connected()
|
|
160
|
+
reclaimed = 0
|
|
161
|
+
while True:
|
|
162
|
+
now = _now()
|
|
163
|
+
doc = self._collection.find_one_and_update(
|
|
164
|
+
{
|
|
165
|
+
"topic": topic,
|
|
166
|
+
"status": "reserved",
|
|
167
|
+
"available_at": {"$lte": now},
|
|
168
|
+
},
|
|
169
|
+
{"$set": {"status": "pending", "available_at": now, "reserved_at": None},
|
|
170
|
+
"$inc": {"attempts": 1}},
|
|
171
|
+
sort=[("available_at", self._pymongo.ASCENDING)],
|
|
172
|
+
return_document=self._pymongo.ReturnDocument.AFTER,
|
|
173
|
+
)
|
|
174
|
+
if doc is None:
|
|
175
|
+
break
|
|
176
|
+
reclaimed += 1
|
|
177
|
+
if doc.get("attempts", 0) >= max_retries:
|
|
178
|
+
# Out of retries — move it to the dead-letter queue and remove
|
|
179
|
+
# the original so it is not re-delivered.
|
|
180
|
+
payload = doc.get("data", {})
|
|
181
|
+
self.dead_letter(topic, {
|
|
182
|
+
"id": doc["_id"],
|
|
183
|
+
"payload": payload,
|
|
184
|
+
"priority": doc.get("priority", 0),
|
|
185
|
+
"attempts": doc.get("attempts", 0),
|
|
186
|
+
"error": "reservation timed out — consumer did not "
|
|
187
|
+
"acknowledge within the visibility timeout",
|
|
188
|
+
})
|
|
189
|
+
self._collection.delete_one({"_id": doc["_id"], "topic": topic})
|
|
190
|
+
return reclaimed
|
|
191
|
+
|
|
129
192
|
def acknowledge(self, topic: str, message_id: str):
|
|
130
193
|
"""Acknowledge a message as processed."""
|
|
131
194
|
self._ensure_connected()
|
|
@@ -211,5 +274,10 @@ class MongoConnector:
|
|
|
211
274
|
def _now() -> str:
|
|
212
275
|
return datetime.now(timezone.utc).isoformat()
|
|
213
276
|
|
|
277
|
+
|
|
278
|
+
def _future(seconds: float) -> str:
|
|
279
|
+
import time
|
|
280
|
+
return datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat()
|
|
281
|
+
|
|
214
282
|
# Backwards-compatible alias — external code and tests may use the old name.
|
|
215
283
|
MongoBackend = MongoConnector
|