tina4-python 3.13.31__tar.gz → 3.13.33__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.31 → tina4_python-3.13.33}/PKG-INFO +1 -1
- {tina4_python-3.13.31 → tina4_python-3.13.33}/pyproject.toml +1 -1
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/cache/__init__.py +34 -1
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/connection.py +31 -18
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue/__init__.py +10 -6
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue/job.py +11 -2
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue/lite_backend.py +147 -70
- {tina4_python-3.13.31 → tina4_python-3.13.33}/.gitignore +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/README.md +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/env.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.31 → tina4_python-3.13.33}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -880,7 +880,10 @@ class ResponseCache:
|
|
|
880
880
|
self._hits += 1
|
|
881
881
|
# Move to end (most recently used)
|
|
882
882
|
self._store.move_to_end(cache_key)
|
|
883
|
-
|
|
883
|
+
remaining = max(0, int(round(entry.expires_at - time.monotonic())))
|
|
884
|
+
hit_response = response(entry.body, entry.status_code)
|
|
885
|
+
self._set_cache_headers(hit_response, "HIT", remaining)
|
|
886
|
+
return request, hit_response
|
|
884
887
|
|
|
885
888
|
self._misses += 1
|
|
886
889
|
|
|
@@ -930,6 +933,11 @@ class ResponseCache:
|
|
|
930
933
|
self._store[cache_key] = entry
|
|
931
934
|
self._store.move_to_end(cache_key)
|
|
932
935
|
|
|
936
|
+
# The handler ran for this request → MISS. Advertise the TTL the
|
|
937
|
+
# entry was just stored with so clients can see how long the next
|
|
938
|
+
# HIT will live.
|
|
939
|
+
self._set_cache_headers(response, "MISS", cache_ttl)
|
|
940
|
+
|
|
933
941
|
return request, response
|
|
934
942
|
|
|
935
943
|
# ── Public API ───────────────────────────────────────────────
|
|
@@ -965,6 +973,31 @@ class ResponseCache:
|
|
|
965
973
|
|
|
966
974
|
# ── Internal helpers ─────────────────────────────────────────
|
|
967
975
|
|
|
976
|
+
@staticmethod
|
|
977
|
+
def _set_cache_headers(response, status: str, ttl: int) -> None:
|
|
978
|
+
"""Set ``X-Cache`` / ``X-Cache-TTL`` on the outgoing response.
|
|
979
|
+
|
|
980
|
+
``status`` is ``"HIT"`` (served from cache) or ``"MISS"`` (handler
|
|
981
|
+
ran). ``ttl`` is the entry's remaining seconds on a HIT, or the
|
|
982
|
+
configured TTL on a MISS. We deliberately do NOT emit
|
|
983
|
+
``Cache-Control`` — browser-cache directives are the app's call.
|
|
984
|
+
|
|
985
|
+
Works with the framework ``Response`` (``add_header``) and with any
|
|
986
|
+
object exposing a ``headers`` dict, so middleware stubs and other
|
|
987
|
+
response shapes keep working.
|
|
988
|
+
"""
|
|
989
|
+
if response is None:
|
|
990
|
+
return
|
|
991
|
+
headers = {"X-Cache": status, "X-Cache-TTL": str(int(ttl))}
|
|
992
|
+
setter = getattr(response, "add_header", None) or getattr(response, "header", None)
|
|
993
|
+
if callable(setter):
|
|
994
|
+
for name, value in headers.items():
|
|
995
|
+
setter(name, value)
|
|
996
|
+
return
|
|
997
|
+
existing = getattr(response, "headers", None)
|
|
998
|
+
if isinstance(existing, dict):
|
|
999
|
+
existing.update(headers)
|
|
1000
|
+
|
|
968
1001
|
@staticmethod
|
|
969
1002
|
def _get_route_ttl(request) -> int | None:
|
|
970
1003
|
"""Check for a per-route cache TTL set via the @cached decorator."""
|
|
@@ -519,7 +519,7 @@ class Database:
|
|
|
519
519
|
return adapter.execute_many(sql, params_list)
|
|
520
520
|
|
|
521
521
|
def fetch(self, sql: str, params: list = None,
|
|
522
|
-
limit: int = 100, offset: int = 0) -> DatabaseResult:
|
|
522
|
+
limit: int = 100, offset: int = 0, no_cache: bool = False) -> DatabaseResult:
|
|
523
523
|
"""Fetch rows with pagination.
|
|
524
524
|
|
|
525
525
|
v3.13.11 (issue #49 Gap 3): mirror :meth:`execute` and capture
|
|
@@ -527,25 +527,32 @@ class Database:
|
|
|
527
527
|
``db.get_error()`` returning ``None`` even though the adapter
|
|
528
528
|
had stored the failure on its own ``last_error``. Callers had
|
|
529
529
|
no way to read the cause via the public API.
|
|
530
|
+
|
|
531
|
+
``no_cache=True`` bypasses the query cache for this one call — no
|
|
532
|
+
lookup and no store — and runs the query straight against the DB.
|
|
533
|
+
Works in either cache mode (request-scoped auto-cache or persistent
|
|
534
|
+
DB cache). The default (``False``) preserves today's behaviour.
|
|
530
535
|
"""
|
|
531
|
-
if self._cache_enabled:
|
|
536
|
+
if self._cache_enabled and not no_cache:
|
|
532
537
|
key = self._cache_key(sql + f":L{limit}:S{offset}", params)
|
|
533
538
|
cached = self._cache_get(key)
|
|
534
539
|
if cached is not None:
|
|
535
540
|
with self._cache_lock:
|
|
536
541
|
self._cache_hits += 1
|
|
537
542
|
return cached
|
|
538
|
-
|
|
539
|
-
try:
|
|
540
|
-
result = adapter.fetch(sql, params, limit, offset)
|
|
541
|
-
self.last_error = None
|
|
542
|
-
except Exception:
|
|
543
|
-
self.last_error = getattr(adapter, "last_error", None) or self.last_error
|
|
544
|
-
raise
|
|
543
|
+
result = self._fetch_direct(sql, params, limit, offset)
|
|
545
544
|
self._cache_set(key, result)
|
|
546
545
|
with self._cache_lock:
|
|
547
546
|
self._cache_misses += 1
|
|
548
547
|
return result
|
|
548
|
+
return self._fetch_direct(sql, params, limit, offset)
|
|
549
|
+
|
|
550
|
+
def _fetch_direct(self, sql: str, params: list, limit: int, offset: int) -> DatabaseResult:
|
|
551
|
+
"""Run a fetch straight against the adapter — no cache lookup or store.
|
|
552
|
+
|
|
553
|
+
Shared by the cached and ``no_cache`` paths so error capture stays
|
|
554
|
+
identical regardless of caching.
|
|
555
|
+
"""
|
|
549
556
|
adapter = self._get_adapter()
|
|
550
557
|
try:
|
|
551
558
|
result = adapter.fetch(sql, params, limit, offset)
|
|
@@ -556,7 +563,7 @@ class Database:
|
|
|
556
563
|
raise
|
|
557
564
|
|
|
558
565
|
def fetch_all(self, sql: str, params: list = None,
|
|
559
|
-
limit: int = 0, offset: int = 0) -> list[dict]:
|
|
566
|
+
limit: int = 0, offset: int = 0, no_cache: bool = False) -> list[dict]:
|
|
560
567
|
"""Fetch ALL rows and return the records list directly.
|
|
561
568
|
|
|
562
569
|
Symmetric with ``fetch_one``. For the common case where you just
|
|
@@ -572,26 +579,32 @@ class Database:
|
|
|
572
579
|
name says ``fetch_all``, so it returns all matching rows. Pre-v3.13.12
|
|
573
580
|
silently truncated to 100. Pass an explicit ``limit=N`` to cap.
|
|
574
581
|
|
|
575
|
-
|
|
582
|
+
``no_cache=True`` bypasses the query cache for this one call (see
|
|
583
|
+
:meth:`fetch`). Returns ``[]`` (not ``None``) when no rows match.
|
|
576
584
|
"""
|
|
577
|
-
return self.fetch(sql, params, limit, offset).records
|
|
585
|
+
return self.fetch(sql, params, limit, offset, no_cache=no_cache).records
|
|
578
586
|
|
|
579
|
-
def fetch_one(self, sql: str, params: list = None) -> dict | None:
|
|
580
|
-
|
|
587
|
+
def fetch_one(self, sql: str, params: list = None, no_cache: bool = False) -> dict | None:
|
|
588
|
+
"""Fetch a single row as a dict, or ``None`` when no row matches.
|
|
589
|
+
|
|
590
|
+
``no_cache=True`` bypasses the query cache for this one call — no
|
|
591
|
+
lookup and no store — and runs the query straight against the DB
|
|
592
|
+
(see :meth:`fetch`). The default (``False``) preserves today's
|
|
593
|
+
behaviour.
|
|
594
|
+
"""
|
|
595
|
+
if self._cache_enabled and not no_cache:
|
|
581
596
|
key = self._cache_key(sql + ":ONE", params)
|
|
582
597
|
cached = self._cache_get(key)
|
|
583
598
|
if cached is not None:
|
|
584
599
|
with self._cache_lock:
|
|
585
600
|
self._cache_hits += 1
|
|
586
601
|
return cached
|
|
587
|
-
|
|
588
|
-
result = adapter.fetch_one(sql, params)
|
|
602
|
+
result = self._get_adapter().fetch_one(sql, params)
|
|
589
603
|
self._cache_set(key, result)
|
|
590
604
|
with self._cache_lock:
|
|
591
605
|
self._cache_misses += 1
|
|
592
606
|
return result
|
|
593
|
-
|
|
594
|
-
return adapter.fetch_one(sql, params)
|
|
607
|
+
return self._get_adapter().fetch_one(sql, params)
|
|
595
608
|
|
|
596
609
|
def insert(self, table: str, data: dict | list) -> DatabaseResult:
|
|
597
610
|
if self._cache_enabled:
|
|
@@ -34,13 +34,13 @@ from tina4_python.queue.kafka_backend import KafkaBackend
|
|
|
34
34
|
from tina4_python.queue.mongo_backend import MongoBackend
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def _resolve_backend(topic: str, backend: str | None, max_retries: int):
|
|
37
|
+
def _resolve_backend(topic: str, backend: str | None, max_retries: int, retry_backoff: int = 0):
|
|
38
38
|
"""Resolve which backend adapter to use."""
|
|
39
39
|
chosen = backend or os.environ.get("TINA4_QUEUE_BACKEND", "file")
|
|
40
40
|
chosen = chosen.lower().strip()
|
|
41
41
|
|
|
42
42
|
if chosen in ("file", "default", "lite"):
|
|
43
|
-
return LiteBackend(topic, max_retries)
|
|
43
|
+
return LiteBackend(topic, max_retries, retry_backoff)
|
|
44
44
|
elif chosen == "rabbitmq":
|
|
45
45
|
return RabbitMQBackend(topic, max_retries)
|
|
46
46
|
elif chosen == "kafka":
|
|
@@ -63,10 +63,13 @@ class Queue:
|
|
|
63
63
|
"""
|
|
64
64
|
|
|
65
65
|
def __init__(self, topic: str = "default", max_retries: int = 3,
|
|
66
|
-
backend: str | None = None):
|
|
66
|
+
backend: str | None = None, retry_backoff: int = 0):
|
|
67
67
|
self.topic = topic
|
|
68
68
|
self.max_retries = max_retries
|
|
69
|
-
|
|
69
|
+
# Seconds to wait before a failed job is re-attempted (file backend).
|
|
70
|
+
# Default 0 = retry on the very next pop()/consume() iteration.
|
|
71
|
+
self.retry_backoff = retry_backoff
|
|
72
|
+
self._backend = _resolve_backend(topic, backend, max_retries, retry_backoff)
|
|
70
73
|
|
|
71
74
|
def push(self, data: dict, priority: int = 0, delay_seconds: int = 0):
|
|
72
75
|
"""Add a job to the queue. Returns job ID."""
|
|
@@ -208,12 +211,12 @@ class Queue:
|
|
|
208
211
|
|
|
209
212
|
old_topic = self.topic
|
|
210
213
|
self.topic = topic
|
|
211
|
-
self._backend = _resolve_backend(topic, None, self.max_retries)
|
|
214
|
+
self._backend = _resolve_backend(topic, None, self.max_retries, self.retry_backoff)
|
|
212
215
|
try:
|
|
213
216
|
return self.push(data, priority, delay_seconds)
|
|
214
217
|
finally:
|
|
215
218
|
self.topic = old_topic
|
|
216
|
-
self._backend = _resolve_backend(old_topic, None, self.max_retries)
|
|
219
|
+
self._backend = _resolve_backend(old_topic, None, self.max_retries, self.retry_backoff)
|
|
217
220
|
|
|
218
221
|
def consume(self, topic: str = None, job_id: str = None, poll_interval: float = 1.0,
|
|
219
222
|
iterations: int = 0, batch_size: int = 1):
|
|
@@ -309,6 +312,7 @@ class Queue:
|
|
|
309
312
|
data=job_data["data"],
|
|
310
313
|
priority=job_data.get("priority", 0),
|
|
311
314
|
attempts=job_data.get("attempts", 0),
|
|
315
|
+
error=job_data.get("error"),
|
|
312
316
|
)
|
|
313
317
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
314
318
|
continue
|
|
@@ -20,11 +20,20 @@ class Job:
|
|
|
20
20
|
return self.payload
|
|
21
21
|
|
|
22
22
|
def complete(self):
|
|
23
|
-
"""Mark job as completed."""
|
|
23
|
+
"""Mark job as completed. Terminal — the job is done and removed."""
|
|
24
24
|
self.queue._backend.complete(self)
|
|
25
25
|
|
|
26
26
|
def fail(self, error: str = ""):
|
|
27
|
-
"""
|
|
27
|
+
"""Record a failed attempt.
|
|
28
|
+
|
|
29
|
+
Increments ``attempts``. If the job still has retries left
|
|
30
|
+
(``attempts < max_retries``) it is automatically re-enqueued to the
|
|
31
|
+
pending queue, so the next ``pop()``/``consume()`` picks it up again
|
|
32
|
+
(after the queue's ``retry_backoff`` delay, if any). Once it has been
|
|
33
|
+
attempted ``max_retries`` times it is moved to the dead-letter store,
|
|
34
|
+
where ``queue.dead_letters()`` returns it. No manual ``retry_failed()``
|
|
35
|
+
is required.
|
|
36
|
+
"""
|
|
28
37
|
self.queue._backend.fail(self, error)
|
|
29
38
|
|
|
30
39
|
def reject(self, reason: str = ""):
|
|
@@ -30,9 +30,13 @@ 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):
|
|
33
|
+
def __init__(self, topic: str, max_retries: int, retry_backoff: int = 0):
|
|
34
34
|
self._topic = topic
|
|
35
35
|
self._max_retries = max_retries
|
|
36
|
+
# Seconds to delay a job's next attempt when it is automatically
|
|
37
|
+
# re-enqueued by fail(). 0 (the default) means retry immediately —
|
|
38
|
+
# the next pop()/consume() iteration picks it up straight away.
|
|
39
|
+
self._retry_backoff = retry_backoff
|
|
36
40
|
self._base_path = os.environ.get("TINA4_QUEUE_PATH", "data/queue")
|
|
37
41
|
self._lock = threading.Lock()
|
|
38
42
|
self._seq = 0
|
|
@@ -74,29 +78,54 @@ class LiteBackend:
|
|
|
74
78
|
json.dump(job, f, indent=2, default=str)
|
|
75
79
|
return job_id
|
|
76
80
|
|
|
81
|
+
def _available_candidates(self, now: str) -> list:
|
|
82
|
+
"""Return (filename, job_data) for every pending, non-delayed job,
|
|
83
|
+
ordered by the dequeue policy: highest priority first, ties broken
|
|
84
|
+
oldest-first by created_at.
|
|
85
|
+
|
|
86
|
+
The file *name* is no longer the ordering key — the stored
|
|
87
|
+
``priority`` and ``created_at`` fields are. This makes pop()/consume()
|
|
88
|
+
honour priority instead of being pure FIFO.
|
|
89
|
+
"""
|
|
90
|
+
queue_dir = self._queue_dir()
|
|
91
|
+
try:
|
|
92
|
+
filenames = os.listdir(queue_dir)
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
candidates = []
|
|
97
|
+
for filename in filenames:
|
|
98
|
+
if not filename.endswith(".queue-data"):
|
|
99
|
+
continue
|
|
100
|
+
filepath = os.path.join(queue_dir, filename)
|
|
101
|
+
try:
|
|
102
|
+
with open(filepath) as f:
|
|
103
|
+
job_data = json.load(f)
|
|
104
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
105
|
+
continue
|
|
106
|
+
if job_data.get("status") != "pending":
|
|
107
|
+
continue
|
|
108
|
+
if job_data.get("available_at", "") > now:
|
|
109
|
+
continue # still delayed
|
|
110
|
+
candidates.append((filename, job_data))
|
|
111
|
+
|
|
112
|
+
# priority DESC, then created_at ASC (oldest first). created_at is an
|
|
113
|
+
# ISO-8601 string so lexicographic order == chronological order.
|
|
114
|
+
candidates.sort(
|
|
115
|
+
key=lambda item: (
|
|
116
|
+
-int(item[1].get("priority", 0) or 0),
|
|
117
|
+
item[1].get("created_at", "") or "",
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return candidates
|
|
121
|
+
|
|
77
122
|
def pop(self, queue_ref) -> Job | None:
|
|
78
123
|
now = _now()
|
|
79
124
|
queue_dir = self._queue_dir()
|
|
80
125
|
|
|
81
126
|
with self._lock:
|
|
82
|
-
|
|
83
|
-
files = sorted(f for f in os.listdir(queue_dir) if f.endswith(".queue-data"))
|
|
84
|
-
except FileNotFoundError:
|
|
85
|
-
return None
|
|
86
|
-
|
|
87
|
-
for filename in files:
|
|
127
|
+
for filename, job_data in self._available_candidates(now):
|
|
88
128
|
filepath = os.path.join(queue_dir, filename)
|
|
89
|
-
try:
|
|
90
|
-
with open(filepath) as f:
|
|
91
|
-
job_data = json.load(f)
|
|
92
|
-
except (json.JSONDecodeError, FileNotFoundError):
|
|
93
|
-
continue
|
|
94
|
-
|
|
95
|
-
if job_data.get("status") != "pending":
|
|
96
|
-
continue
|
|
97
|
-
if job_data.get("available_at", "") > now:
|
|
98
|
-
continue
|
|
99
|
-
|
|
100
129
|
# Claim the job by deleting the file
|
|
101
130
|
try:
|
|
102
131
|
os.unlink(filepath)
|
|
@@ -110,37 +139,25 @@ class LiteBackend:
|
|
|
110
139
|
data=job_data["data"],
|
|
111
140
|
priority=job_data.get("priority", 0),
|
|
112
141
|
attempts=job_data.get("attempts", 0),
|
|
142
|
+
error=job_data.get("error"),
|
|
113
143
|
)
|
|
114
144
|
|
|
115
145
|
return None
|
|
116
146
|
|
|
117
147
|
def pop_batch(self, count: int, queue_ref) -> list:
|
|
118
|
-
"""Pop up to count jobs in one operation
|
|
148
|
+
"""Pop up to count jobs in one operation, highest priority first.
|
|
149
|
+
|
|
150
|
+
Returns a partial batch if fewer than ``count`` are available.
|
|
151
|
+
"""
|
|
119
152
|
now = _now()
|
|
120
153
|
queue_dir = self._queue_dir()
|
|
121
154
|
results = []
|
|
122
155
|
|
|
123
156
|
with self._lock:
|
|
124
|
-
|
|
125
|
-
files = sorted(f for f in os.listdir(queue_dir) if f.endswith(".queue-data"))
|
|
126
|
-
except FileNotFoundError:
|
|
127
|
-
return []
|
|
128
|
-
|
|
129
|
-
for filename in files:
|
|
157
|
+
for filename, job_data in self._available_candidates(now):
|
|
130
158
|
if len(results) >= count:
|
|
131
159
|
break
|
|
132
160
|
filepath = os.path.join(queue_dir, filename)
|
|
133
|
-
try:
|
|
134
|
-
with open(filepath) as f:
|
|
135
|
-
job_data = json.load(f)
|
|
136
|
-
except (json.JSONDecodeError, FileNotFoundError):
|
|
137
|
-
continue
|
|
138
|
-
|
|
139
|
-
if job_data.get("status") != "pending":
|
|
140
|
-
continue
|
|
141
|
-
if job_data.get("available_at", "") > now:
|
|
142
|
-
continue
|
|
143
|
-
|
|
144
161
|
try:
|
|
145
162
|
os.unlink(filepath)
|
|
146
163
|
except FileNotFoundError:
|
|
@@ -153,45 +170,57 @@ class LiteBackend:
|
|
|
153
170
|
data=job_data["data"],
|
|
154
171
|
priority=job_data.get("priority", 0),
|
|
155
172
|
attempts=job_data.get("attempts", 0),
|
|
173
|
+
error=job_data.get("error"),
|
|
156
174
|
))
|
|
157
175
|
|
|
158
176
|
return results
|
|
159
177
|
|
|
178
|
+
# Status aliases that live in the failed/ directory (dead-lettered jobs)
|
|
179
|
+
# rather than as pending files in the queue directory.
|
|
180
|
+
_DEAD_STATES = ("failed", "dead", "dead_letter")
|
|
181
|
+
|
|
160
182
|
def size(self, status: str = "pending") -> int:
|
|
161
|
-
|
|
183
|
+
scan_dir = self._failed_dir() if status in self._DEAD_STATES else self._queue_dir()
|
|
162
184
|
count = 0
|
|
163
185
|
try:
|
|
164
|
-
for filename in os.listdir(
|
|
186
|
+
for filename in os.listdir(scan_dir):
|
|
165
187
|
if not filename.endswith(".queue-data"):
|
|
166
188
|
continue
|
|
167
|
-
filepath = os.path.join(
|
|
189
|
+
filepath = os.path.join(scan_dir, filename)
|
|
168
190
|
try:
|
|
169
191
|
with open(filepath) as f:
|
|
170
192
|
job_data = json.load(f)
|
|
171
|
-
if job_data.get("status") == status:
|
|
172
|
-
count += 1
|
|
173
193
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
174
194
|
continue
|
|
195
|
+
if status in self._DEAD_STATES:
|
|
196
|
+
# Every file in failed/ is a dead-letter; count them all
|
|
197
|
+
# regardless of the exact stored status string.
|
|
198
|
+
count += 1
|
|
199
|
+
elif job_data.get("status") == status:
|
|
200
|
+
count += 1
|
|
175
201
|
except FileNotFoundError:
|
|
176
202
|
pass
|
|
177
203
|
return count
|
|
178
204
|
|
|
179
205
|
def purge(self, status: str = "completed") -> int:
|
|
180
|
-
|
|
206
|
+
scan_dir = self._failed_dir() if status in self._DEAD_STATES else self._queue_dir()
|
|
181
207
|
count = 0
|
|
182
208
|
try:
|
|
183
|
-
for filename in os.listdir(
|
|
209
|
+
for filename in os.listdir(scan_dir):
|
|
184
210
|
if not filename.endswith(".queue-data"):
|
|
185
211
|
continue
|
|
186
|
-
filepath = os.path.join(
|
|
212
|
+
filepath = os.path.join(scan_dir, filename)
|
|
187
213
|
try:
|
|
188
214
|
with open(filepath) as f:
|
|
189
215
|
job_data = json.load(f)
|
|
190
|
-
if job_data.get("status") == status:
|
|
191
|
-
os.unlink(filepath)
|
|
192
|
-
count += 1
|
|
193
216
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
194
217
|
continue
|
|
218
|
+
if status in self._DEAD_STATES or job_data.get("status") == status:
|
|
219
|
+
try:
|
|
220
|
+
os.unlink(filepath)
|
|
221
|
+
count += 1
|
|
222
|
+
except FileNotFoundError:
|
|
223
|
+
continue
|
|
195
224
|
except FileNotFoundError:
|
|
196
225
|
pass
|
|
197
226
|
return count
|
|
@@ -212,6 +241,7 @@ class LiteBackend:
|
|
|
212
241
|
if job_data.get("attempts", 0) < limit:
|
|
213
242
|
job_data["status"] = "pending"
|
|
214
243
|
job_data["available_at"] = _now()
|
|
244
|
+
job_data["created_at"] = _now()
|
|
215
245
|
prefix = self._next_prefix()
|
|
216
246
|
new_path = os.path.join(queue_dir, f"{prefix}_{job_data['id']}.queue-data")
|
|
217
247
|
with open(new_path, "w") as f:
|
|
@@ -225,20 +255,28 @@ class LiteBackend:
|
|
|
225
255
|
return count
|
|
226
256
|
|
|
227
257
|
def failed(self) -> list[dict]:
|
|
228
|
-
|
|
258
|
+
"""Jobs that have failed at least once but are still being retried.
|
|
259
|
+
|
|
260
|
+
Under the auto-retry lifecycle a failed-but-retryable job lives in the
|
|
261
|
+
pending queue (not the dead-letter dir), so this scans the queue dir
|
|
262
|
+
for pending jobs with ``attempts > 0`` that have not yet exhausted
|
|
263
|
+
their retries. Dead-lettered jobs are returned by dead_letters().
|
|
264
|
+
"""
|
|
265
|
+
queue_dir = self._queue_dir()
|
|
229
266
|
results = []
|
|
230
267
|
try:
|
|
231
|
-
for filename in sorted(os.listdir(
|
|
268
|
+
for filename in sorted(os.listdir(queue_dir)):
|
|
232
269
|
if not filename.endswith(".queue-data"):
|
|
233
270
|
continue
|
|
234
|
-
filepath = os.path.join(
|
|
271
|
+
filepath = os.path.join(queue_dir, filename)
|
|
235
272
|
try:
|
|
236
273
|
with open(filepath) as f:
|
|
237
274
|
job_data = json.load(f)
|
|
238
|
-
if job_data.get("attempts", 0) < self._max_retries:
|
|
239
|
-
results.append(job_data)
|
|
240
275
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
241
276
|
continue
|
|
277
|
+
attempts = job_data.get("attempts", 0)
|
|
278
|
+
if attempts > 0 and attempts < self._max_retries:
|
|
279
|
+
results.append(job_data)
|
|
242
280
|
except FileNotFoundError:
|
|
243
281
|
pass
|
|
244
282
|
return results
|
|
@@ -264,17 +302,22 @@ class LiteBackend:
|
|
|
264
302
|
return results
|
|
265
303
|
|
|
266
304
|
def retry_job(self, job_id: str, delay_seconds: int = 0) -> bool:
|
|
305
|
+
"""Revive a specific dead-letter job by id back to the pending queue.
|
|
306
|
+
|
|
307
|
+
This is a manual override (Queue.retry(job_id)) — it always revives a
|
|
308
|
+
dead-letter regardless of attempt count, mirroring job.retry(). Returns
|
|
309
|
+
False only if no dead-letter with that id exists.
|
|
310
|
+
"""
|
|
267
311
|
failed_dir = self._failed_dir()
|
|
268
312
|
queue_dir = self._queue_dir()
|
|
269
313
|
filepath = os.path.join(failed_dir, f"{job_id}.queue-data")
|
|
270
314
|
try:
|
|
271
315
|
with open(filepath) as f:
|
|
272
316
|
job_data = json.load(f)
|
|
273
|
-
if job_data.get("attempts", 0) >= self._max_retries:
|
|
274
|
-
return False
|
|
275
317
|
job_data["status"] = "pending"
|
|
276
318
|
job_data["error"] = None
|
|
277
319
|
job_data["attempts"] = job_data.get("attempts", 0) + 1
|
|
320
|
+
job_data["created_at"] = _now()
|
|
278
321
|
if delay_seconds > 0:
|
|
279
322
|
job_data["available_at"] = _future(delay_seconds)
|
|
280
323
|
else:
|
|
@@ -305,41 +348,75 @@ class LiteBackend:
|
|
|
305
348
|
return count
|
|
306
349
|
|
|
307
350
|
def complete(self, job: Job):
|
|
308
|
-
# Job file was already deleted on pop — nothing to do
|
|
351
|
+
# Job file was already deleted on pop — nothing to do. complete() is
|
|
352
|
+
# terminal: the job is done and gone.
|
|
309
353
|
pass
|
|
310
354
|
|
|
311
|
-
def
|
|
312
|
-
job
|
|
355
|
+
def _requeue(self, job: Job, delay_seconds: int = 0, error: str | None = None):
|
|
356
|
+
"""Write the job back to the pending queue (queue dir).
|
|
357
|
+
|
|
358
|
+
Re-enqueued jobs get a fresh ``created_at`` so within a priority tier
|
|
359
|
+
they sort behind jobs that have not yet been attempted. ``attempts``
|
|
360
|
+
already reflects the latest failure count.
|
|
361
|
+
"""
|
|
362
|
+
available = _now() if delay_seconds <= 0 else _future(delay_seconds)
|
|
313
363
|
job_data = {
|
|
314
364
|
"id": job.id,
|
|
315
365
|
"topic": job.topic,
|
|
316
366
|
"data": job.payload,
|
|
317
|
-
"status": "
|
|
367
|
+
"status": "pending",
|
|
318
368
|
"priority": job.priority,
|
|
319
369
|
"attempts": job.attempts,
|
|
320
370
|
"error": error,
|
|
321
|
-
"
|
|
371
|
+
"available_at": available,
|
|
372
|
+
"created_at": _now(),
|
|
322
373
|
}
|
|
323
|
-
|
|
324
|
-
os.
|
|
325
|
-
filepath = os.path.join(failed_dir, f"{job.id}.queue-data")
|
|
374
|
+
prefix = self._next_prefix()
|
|
375
|
+
filepath = os.path.join(self._queue_dir(), f"{prefix}_{job.id}.queue-data")
|
|
326
376
|
with open(filepath, "w") as f:
|
|
327
377
|
json.dump(job_data, f, indent=2, default=str)
|
|
328
378
|
|
|
329
|
-
def
|
|
330
|
-
job.
|
|
331
|
-
|
|
379
|
+
def _dead_letter(self, job: Job, error: str = ""):
|
|
380
|
+
"""Move the job to the dead-letter (failed) directory. Terminal until
|
|
381
|
+
a manual retry_failed()/retry() revives it."""
|
|
332
382
|
job_data = {
|
|
333
383
|
"id": job.id,
|
|
334
384
|
"topic": job.topic,
|
|
335
385
|
"data": job.payload,
|
|
336
|
-
"status": "
|
|
386
|
+
"status": "dead",
|
|
337
387
|
"priority": job.priority,
|
|
338
388
|
"attempts": job.attempts,
|
|
339
|
-
"
|
|
340
|
-
"
|
|
389
|
+
"error": error,
|
|
390
|
+
"failed_at": _now(),
|
|
341
391
|
}
|
|
342
|
-
|
|
343
|
-
|
|
392
|
+
failed_dir = self._failed_dir()
|
|
393
|
+
os.makedirs(failed_dir, exist_ok=True)
|
|
394
|
+
filepath = os.path.join(failed_dir, f"{job.id}.queue-data")
|
|
344
395
|
with open(filepath, "w") as f:
|
|
345
396
|
json.dump(job_data, f, indent=2, default=str)
|
|
397
|
+
|
|
398
|
+
def fail(self, job: Job, error: str = ""):
|
|
399
|
+
"""Record a failed attempt.
|
|
400
|
+
|
|
401
|
+
Increments ``attempts``. If the job still has retries left
|
|
402
|
+
(``attempts < max_retries``) it is automatically re-enqueued to the
|
|
403
|
+
pending queue — so the next pop()/consume() iteration picks it up
|
|
404
|
+
again — after an optional ``retry_backoff`` delay. Once it has been
|
|
405
|
+
attempted ``max_retries`` times (``attempts >= max_retries``) it is
|
|
406
|
+
moved to the dead-letter store, where dead_letters() returns it.
|
|
407
|
+
"""
|
|
408
|
+
job.attempts += 1
|
|
409
|
+
job.error = error
|
|
410
|
+
if job.attempts < self._max_retries:
|
|
411
|
+
self._requeue(job, delay_seconds=self._retry_backoff, error=error)
|
|
412
|
+
else:
|
|
413
|
+
self._dead_letter(job, error)
|
|
414
|
+
|
|
415
|
+
def retry(self, job: Job, delay_seconds: int = 0):
|
|
416
|
+
"""Explicit re-queue requested by the caller (job.retry()).
|
|
417
|
+
|
|
418
|
+
Always re-enqueues regardless of the retry limit — this is a manual
|
|
419
|
+
override, distinct from the automatic fail() path.
|
|
420
|
+
"""
|
|
421
|
+
job.attempts += 1
|
|
422
|
+
self._requeue(job, delay_seconds=delay_seconds, error=None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|