tina4-python 3.13.23__tar.gz → 3.13.24__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.23 → tina4_python-3.13.24}/PKG-INFO +1 -1
- {tina4_python-3.13.23 → tina4_python-3.13.24}/pyproject.toml +1 -1
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/CLAUDE.md +10 -3
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/cache/__init__.py +375 -16
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/connection.py +55 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/.gitignore +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/README.md +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/env.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.23 → tina4_python-3.13.24}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1457,10 +1457,17 @@ clear_cache() # flush all entries and reset stats
|
|
|
1457
1457
|
### Environment variables
|
|
1458
1458
|
|
|
1459
1459
|
```bash
|
|
1460
|
+
TINA4_CACHE_BACKEND=memory # memory (default) | file | redis | valkey | memcached | mongodb | database
|
|
1461
|
+
TINA4_CACHE_URL= # connection for redis/valkey/memcached/mongodb, OR a SQL URL for database (falls back to TINA4_DATABASE_URL)
|
|
1462
|
+
TINA4_CACHE_USERNAME= # credentials (mirrors TINA4_DATABASE_USERNAME); may also be embedded in TINA4_CACHE_URL
|
|
1463
|
+
TINA4_CACHE_PASSWORD= # e.g. redis://user:pass@host, redis://:pass@host, mongodb://user:pass@host. memcached is unauthenticated
|
|
1460
1464
|
TINA4_CACHE_TTL=60 # Default TTL in seconds (default: 60)
|
|
1461
1465
|
TINA4_CACHE_MAX_ENTRIES=1000 # Max cached entries (default: 1000)
|
|
1466
|
+
TINA4_CACHE_DIR=data/cache # Directory for the file backend (default: data/cache)
|
|
1462
1467
|
```
|
|
1463
1468
|
|
|
1469
|
+
The response/KV cache supports seven backends, selected by `TINA4_CACHE_BACKEND`. **Graceful fallback**: if a configured backend's driver is missing or the service/credentials are unreachable or wrong, the cache logs a warning and falls back to the **file** backend — a real persistent cache, never a silent no-op.
|
|
1470
|
+
|
|
1464
1471
|
## DI Container — Dependency Injection
|
|
1465
1472
|
|
|
1466
1473
|
Lightweight, thread-safe dependency injection container with transient and singleton registrations.
|
|
@@ -1836,13 +1843,13 @@ async def dashboard(request, response):
|
|
|
1836
1843
|
## v3 Features Summary
|
|
1837
1844
|
|
|
1838
1845
|
- **55 built-in features**, zero third-party dependencies
|
|
1839
|
-
- **2,
|
|
1846
|
+
- **2,899 tests** passing across all modules
|
|
1840
1847
|
- **Production server auto-detect**: `tina4python serve --production` auto-installs uvicorn
|
|
1841
1848
|
- **`tina4python generate`**: model, route, migration, middleware scaffolding
|
|
1842
|
-
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **on by default** (`TINA4_AUTO_CACHING=true`, TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical reads within a request and flushes on writes; persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s); `cache_stats()` reports `mode` (request/persistent/off)
|
|
1849
|
+
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **on by default** (`TINA4_AUTO_CACHING=true`, TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical reads within a request and flushes on writes; persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL` so instances share one cache with global write-invalidation; `cache_stats()` reports `mode` (request/persistent/off) and `backend`, `cache_clear()`
|
|
1843
1850
|
- **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
|
|
1844
1851
|
- **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
|
|
1845
|
-
- **Cache**: memory
|
|
1852
|
+
- **Cache**: unified backend set — memory (default), file, redis, valkey, memcached, mongodb, database — via `TINA4_CACHE_BACKEND` (+ `TINA4_CACHE_URL`/credentials); file-backend fallback if a backend is unreachable
|
|
1846
1853
|
- **Messenger**: .env driven SMTP/IMAP
|
|
1847
1854
|
- **ORM relationships**: `has_many`, `has_one`, `belongs_to` with eager loading (`include=`)
|
|
1848
1855
|
- **Frond pre-compilation**: 2.8x template render improvement, `Frond.clear_cache()`
|
|
@@ -66,6 +66,12 @@ class _CacheBackend:
|
|
|
66
66
|
def name(self) -> str:
|
|
67
67
|
raise NotImplementedError
|
|
68
68
|
|
|
69
|
+
def is_available(self) -> bool:
|
|
70
|
+
"""Whether this backend is actually usable (driver present + service
|
|
71
|
+
reachable). Local backends are always available; network/driver backends
|
|
72
|
+
override this so the factory can fall back to the file backend."""
|
|
73
|
+
return True
|
|
74
|
+
|
|
69
75
|
|
|
70
76
|
# ── Memory backend ─────────────────────────────────────────────────
|
|
71
77
|
|
|
@@ -141,34 +147,58 @@ class _RedisBackend(_CacheBackend):
|
|
|
141
147
|
"""Redis / Valkey backend. Uses the ``redis`` package if available,
|
|
142
148
|
otherwise falls back to raw RESP protocol over TCP."""
|
|
143
149
|
|
|
144
|
-
def __init__(self, url: str = "redis://localhost:6379", max_entries: int = 1000
|
|
150
|
+
def __init__(self, url: str = "redis://localhost:6379", max_entries: int = 1000,
|
|
151
|
+
name: str = "redis"):
|
|
145
152
|
self._url = url
|
|
146
153
|
self._max_entries = max_entries
|
|
154
|
+
self._name = name
|
|
147
155
|
self._hits = 0
|
|
148
156
|
self._misses = 0
|
|
149
157
|
self._prefix = "tina4:cache:"
|
|
150
158
|
self._client = None
|
|
151
159
|
self._use_raw = False
|
|
152
160
|
|
|
153
|
-
# Parse URL
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
self.
|
|
161
|
+
# Parse URL: scheme://[user[:password]@]host[:port][/db]. Credentials may
|
|
162
|
+
# also be supplied via TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD
|
|
163
|
+
# (mirrors TINA4_DATABASE_USERNAME / TINA4_DATABASE_PASSWORD).
|
|
164
|
+
from urllib.parse import urlparse, unquote
|
|
165
|
+
parsed = urlparse(url if "://" in url else "redis://" + url)
|
|
166
|
+
self._host = parsed.hostname or "localhost"
|
|
167
|
+
self._port = parsed.port or 6379
|
|
168
|
+
db_path = (parsed.path or "").lstrip("/")
|
|
169
|
+
self._db = int(db_path) if db_path.isdigit() else 0
|
|
170
|
+
self._username = (unquote(parsed.username) if parsed.username
|
|
171
|
+
else os.environ.get("TINA4_CACHE_USERNAME", "")) or None
|
|
172
|
+
self._password = (unquote(parsed.password) if parsed.password
|
|
173
|
+
else os.environ.get("TINA4_CACHE_PASSWORD", "")) or None
|
|
160
174
|
|
|
161
175
|
# Try the redis package first
|
|
162
176
|
try:
|
|
163
177
|
import redis as redis_pkg
|
|
164
|
-
self.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
178
|
+
kwargs = dict(host=self._host, port=self._port, db=self._db,
|
|
179
|
+
decode_responses=True, socket_timeout=5)
|
|
180
|
+
if self._password:
|
|
181
|
+
kwargs["password"] = self._password
|
|
182
|
+
if self._username:
|
|
183
|
+
kwargs["username"] = self._username
|
|
184
|
+
self._client = redis_pkg.Redis(**kwargs)
|
|
168
185
|
self._client.ping()
|
|
186
|
+
self._available = True
|
|
169
187
|
except Exception:
|
|
170
188
|
self._client = None
|
|
171
189
|
self._use_raw = True
|
|
190
|
+
# No client — usable only if the server answers (and authenticates).
|
|
191
|
+
self._available = self._probe()
|
|
192
|
+
|
|
193
|
+
def _probe(self) -> bool:
|
|
194
|
+
# Real AUTH+PING handshake so wrong credentials also fall back to file.
|
|
195
|
+
try:
|
|
196
|
+
return self._resp_command("PING") == "PONG"
|
|
197
|
+
except Exception:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
def is_available(self) -> bool:
|
|
201
|
+
return self._available
|
|
172
202
|
|
|
173
203
|
def _resp_command(self, *args) -> str | None:
|
|
174
204
|
"""Send a command using raw RESP protocol over TCP."""
|
|
@@ -181,6 +211,16 @@ class _RedisBackend(_CacheBackend):
|
|
|
181
211
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
182
212
|
sock.settimeout(5)
|
|
183
213
|
sock.connect((self._host, self._port))
|
|
214
|
+
if self._password:
|
|
215
|
+
if self._username:
|
|
216
|
+
auth = (f"*3\r\n$4\r\nAUTH\r\n${len(self._username)}\r\n{self._username}\r\n"
|
|
217
|
+
f"${len(self._password)}\r\n{self._password}\r\n")
|
|
218
|
+
else:
|
|
219
|
+
auth = f"*2\r\n$4\r\nAUTH\r\n${len(self._password)}\r\n{self._password}\r\n"
|
|
220
|
+
sock.sendall(auth.encode())
|
|
221
|
+
if not sock.recv(1024).startswith(b"+"):
|
|
222
|
+
sock.close()
|
|
223
|
+
return None
|
|
184
224
|
if self._db != 0:
|
|
185
225
|
select_cmd = f"*2\r\n$6\r\nSELECT\r\n${len(str(self._db))}\r\n{self._db}\r\n"
|
|
186
226
|
sock.sendall(select_cmd.encode())
|
|
@@ -280,11 +320,11 @@ class _RedisBackend(_CacheBackend):
|
|
|
280
320
|
"hits": self._hits,
|
|
281
321
|
"misses": self._misses,
|
|
282
322
|
"size": size,
|
|
283
|
-
"backend":
|
|
323
|
+
"backend": self._name,
|
|
284
324
|
}
|
|
285
325
|
|
|
286
326
|
def name(self) -> str:
|
|
287
|
-
return
|
|
327
|
+
return self._name
|
|
288
328
|
|
|
289
329
|
|
|
290
330
|
# ── File backend ───────────────────────────────────────────────────
|
|
@@ -386,6 +426,295 @@ class _FileBackend(_CacheBackend):
|
|
|
386
426
|
return "file"
|
|
387
427
|
|
|
388
428
|
|
|
429
|
+
# ── Valkey backend (Redis-protocol compatible) ─────────────────────
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class _ValkeyBackend(_RedisBackend):
|
|
433
|
+
"""Valkey backend. Valkey speaks the Redis wire protocol, so it reuses the
|
|
434
|
+
Redis client / raw-RESP transport and only reports a different name."""
|
|
435
|
+
|
|
436
|
+
def __init__(self, url: str = "valkey://localhost:6379", max_entries: int = 1000):
|
|
437
|
+
super().__init__(url=url.replace("valkey://", "redis://"),
|
|
438
|
+
max_entries=max_entries, name="valkey")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ── Memcached backend (zero-dependency text protocol) ──────────────
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class _MemcachedBackend(_CacheBackend):
|
|
445
|
+
"""Memcached backend using the zero-dependency text protocol over TCP."""
|
|
446
|
+
|
|
447
|
+
def __init__(self, url: str = "memcached://localhost:11211", max_entries: int = 1000):
|
|
448
|
+
cleaned = url.replace("memcached://", "").replace("memcache://", "")
|
|
449
|
+
parts = cleaned.split("/")[0].split(":")
|
|
450
|
+
self._host = parts[0] or "localhost"
|
|
451
|
+
self._port = int(parts[1]) if len(parts) > 1 and parts[1] else 11211
|
|
452
|
+
self._max_entries = max_entries
|
|
453
|
+
self._prefix = "tina4:cache:"
|
|
454
|
+
self._hits = 0
|
|
455
|
+
self._misses = 0
|
|
456
|
+
self._available = self._command(b"version\r\n", b"\r\n").startswith(b"VERSION")
|
|
457
|
+
|
|
458
|
+
def is_available(self) -> bool:
|
|
459
|
+
return self._available
|
|
460
|
+
|
|
461
|
+
def _mc_key(self, key: str) -> str:
|
|
462
|
+
# Hash to a safe, bounded key (memcached keys: no spaces/control, <=250 chars)
|
|
463
|
+
return self._prefix + hashlib.sha256(key.encode()).hexdigest()
|
|
464
|
+
|
|
465
|
+
def _command(self, payload: bytes, terminator: bytes) -> bytes:
|
|
466
|
+
try:
|
|
467
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
468
|
+
sock.settimeout(5)
|
|
469
|
+
sock.connect((self._host, self._port))
|
|
470
|
+
sock.sendall(payload)
|
|
471
|
+
buf = b""
|
|
472
|
+
while terminator not in buf:
|
|
473
|
+
chunk = sock.recv(4096)
|
|
474
|
+
if not chunk:
|
|
475
|
+
break
|
|
476
|
+
buf += chunk
|
|
477
|
+
sock.close()
|
|
478
|
+
return buf
|
|
479
|
+
except Exception:
|
|
480
|
+
return b""
|
|
481
|
+
|
|
482
|
+
def get(self, key: str):
|
|
483
|
+
resp = self._command(f"get {self._mc_key(key)}\r\n".encode(), b"END\r\n")
|
|
484
|
+
if resp.startswith(b"VALUE"):
|
|
485
|
+
try:
|
|
486
|
+
header, rest = resp.split(b"\r\n", 1)
|
|
487
|
+
nbytes = int(header.split()[3])
|
|
488
|
+
self._hits += 1
|
|
489
|
+
return json.loads(rest[:nbytes].decode())
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
self._misses += 1
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
def set(self, key: str, value, ttl: int):
|
|
496
|
+
data = json.dumps(value, default=str).encode()
|
|
497
|
+
exptime = ttl if ttl > 0 else 0
|
|
498
|
+
payload = f"set {self._mc_key(key)} 0 {exptime} {len(data)}\r\n".encode() + data + b"\r\n"
|
|
499
|
+
self._command(payload, b"\r\n")
|
|
500
|
+
|
|
501
|
+
def delete(self, key: str) -> bool:
|
|
502
|
+
resp = self._command(f"delete {self._mc_key(key)}\r\n".encode(), b"\r\n")
|
|
503
|
+
return resp.startswith(b"DELETED")
|
|
504
|
+
|
|
505
|
+
def clear(self):
|
|
506
|
+
self._hits = 0
|
|
507
|
+
self._misses = 0
|
|
508
|
+
self._command(b"flush_all\r\n", b"\r\n")
|
|
509
|
+
|
|
510
|
+
def stats(self) -> dict:
|
|
511
|
+
size = 0
|
|
512
|
+
resp = self._command(b"stats\r\n", b"END\r\n")
|
|
513
|
+
for line in resp.split(b"\r\n"):
|
|
514
|
+
if line.startswith(b"STAT curr_items "):
|
|
515
|
+
try:
|
|
516
|
+
size = int(line.split()[2])
|
|
517
|
+
except (ValueError, IndexError):
|
|
518
|
+
pass
|
|
519
|
+
return {"hits": self._hits, "misses": self._misses, "size": size, "backend": "memcached"}
|
|
520
|
+
|
|
521
|
+
def name(self) -> str:
|
|
522
|
+
return "memcached"
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ── MongoDB backend (TTL collection) ───────────────────────────────
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class _MongoBackend(_CacheBackend):
|
|
529
|
+
"""MongoDB backend backed by a TTL collection. Requires ``pymongo``.
|
|
530
|
+
|
|
531
|
+
Reuses the same connection style as the Mongo session handler; the cache
|
|
532
|
+
lives in collection ``tina4_cache`` with a TTL index on ``expires_at``."""
|
|
533
|
+
|
|
534
|
+
def __init__(self, url: str = "mongodb://localhost:27017", max_entries: int = 1000):
|
|
535
|
+
self._max_entries = max_entries
|
|
536
|
+
self._hits = 0
|
|
537
|
+
self._misses = 0
|
|
538
|
+
self._coll = None
|
|
539
|
+
try:
|
|
540
|
+
import pymongo
|
|
541
|
+
mongo_kwargs = {"serverSelectionTimeoutMS": 5000}
|
|
542
|
+
# Credentials from env when not embedded in the URL (parity with DB layer).
|
|
543
|
+
if "@" not in url:
|
|
544
|
+
mu = os.environ.get("TINA4_CACHE_USERNAME")
|
|
545
|
+
mp = os.environ.get("TINA4_CACHE_PASSWORD")
|
|
546
|
+
if mu:
|
|
547
|
+
mongo_kwargs["username"] = mu
|
|
548
|
+
if mp:
|
|
549
|
+
mongo_kwargs["password"] = mp
|
|
550
|
+
client = pymongo.MongoClient(url, **mongo_kwargs)
|
|
551
|
+
try:
|
|
552
|
+
db = client.get_default_database()
|
|
553
|
+
except Exception:
|
|
554
|
+
db = None
|
|
555
|
+
if db is None:
|
|
556
|
+
db = client["tina4_cache"]
|
|
557
|
+
self._coll = db["tina4_cache"]
|
|
558
|
+
self._coll.create_index("expires_at", expireAfterSeconds=0)
|
|
559
|
+
client.admin.command("ping")
|
|
560
|
+
except Exception:
|
|
561
|
+
self._coll = None
|
|
562
|
+
|
|
563
|
+
def is_available(self) -> bool:
|
|
564
|
+
return self._coll is not None
|
|
565
|
+
|
|
566
|
+
def get(self, key: str):
|
|
567
|
+
if self._coll is None:
|
|
568
|
+
self._misses += 1
|
|
569
|
+
return None
|
|
570
|
+
try:
|
|
571
|
+
from datetime import datetime, timezone
|
|
572
|
+
doc = self._coll.find_one({"_id": key})
|
|
573
|
+
if not doc:
|
|
574
|
+
self._misses += 1
|
|
575
|
+
return None
|
|
576
|
+
exp = doc.get("expires_at")
|
|
577
|
+
if exp is not None and exp.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
|
|
578
|
+
self._coll.delete_one({"_id": key})
|
|
579
|
+
self._misses += 1
|
|
580
|
+
return None
|
|
581
|
+
self._hits += 1
|
|
582
|
+
return json.loads(doc["value"])
|
|
583
|
+
except Exception:
|
|
584
|
+
self._misses += 1
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
def set(self, key: str, value, ttl: int):
|
|
588
|
+
if self._coll is None:
|
|
589
|
+
return
|
|
590
|
+
try:
|
|
591
|
+
from datetime import datetime, timedelta, timezone
|
|
592
|
+
doc = {"_id": key, "value": json.dumps(value, default=str)}
|
|
593
|
+
if ttl > 0:
|
|
594
|
+
doc["expires_at"] = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
|
595
|
+
self._coll.replace_one({"_id": key}, doc, upsert=True)
|
|
596
|
+
except Exception:
|
|
597
|
+
pass
|
|
598
|
+
|
|
599
|
+
def delete(self, key: str) -> bool:
|
|
600
|
+
if self._coll is None:
|
|
601
|
+
return False
|
|
602
|
+
try:
|
|
603
|
+
return self._coll.delete_one({"_id": key}).deleted_count > 0
|
|
604
|
+
except Exception:
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
def clear(self):
|
|
608
|
+
self._hits = 0
|
|
609
|
+
self._misses = 0
|
|
610
|
+
if self._coll is not None:
|
|
611
|
+
try:
|
|
612
|
+
self._coll.delete_many({})
|
|
613
|
+
except Exception:
|
|
614
|
+
pass
|
|
615
|
+
|
|
616
|
+
def stats(self) -> dict:
|
|
617
|
+
size = 0
|
|
618
|
+
if self._coll is not None:
|
|
619
|
+
try:
|
|
620
|
+
size = self._coll.count_documents({})
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
return {"hits": self._hits, "misses": self._misses, "size": size, "backend": "mongodb"}
|
|
624
|
+
|
|
625
|
+
def name(self) -> str:
|
|
626
|
+
return "mongodb"
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ── Database backend (cache table in any Tina4-supported DB) ───────
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class _DatabaseBackend(_CacheBackend):
|
|
633
|
+
"""Database backend — stores entries in a ``tina4_cache`` table in any
|
|
634
|
+
Tina4-supported database. Zero extra infrastructure; reuses the Database
|
|
635
|
+
layer. Its own connection has query caching disabled to avoid recursion."""
|
|
636
|
+
|
|
637
|
+
def __init__(self, url: str | None = None, max_entries: int = 1000):
|
|
638
|
+
from tina4_python.database.connection import Database
|
|
639
|
+
self._max_entries = max_entries
|
|
640
|
+
self._hits = 0
|
|
641
|
+
self._misses = 0
|
|
642
|
+
self._db = None
|
|
643
|
+
self._available = False
|
|
644
|
+
# The database backend reads TINA4_CACHE_URL (interpreted as a SQL URL),
|
|
645
|
+
# falling back to the app's own TINA4_DATABASE_URL — cache in the DB you
|
|
646
|
+
# already run, no extra connection var needed.
|
|
647
|
+
url = (url or os.environ.get("TINA4_CACHE_URL")
|
|
648
|
+
or os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/tina4.db"))
|
|
649
|
+
# The cache's own DB connection must not itself cache (no recursion).
|
|
650
|
+
prev_auto = os.environ.get("TINA4_AUTO_CACHING")
|
|
651
|
+
prev_db = os.environ.get("TINA4_DB_CACHE")
|
|
652
|
+
os.environ["TINA4_AUTO_CACHING"] = "false"
|
|
653
|
+
os.environ["TINA4_DB_CACHE"] = "false"
|
|
654
|
+
try:
|
|
655
|
+
self._db = Database(url)
|
|
656
|
+
self._db.execute(
|
|
657
|
+
"CREATE TABLE IF NOT EXISTS tina4_cache "
|
|
658
|
+
"(cache_key VARCHAR(255) PRIMARY KEY, value TEXT, expires_at DOUBLE PRECISION)"
|
|
659
|
+
)
|
|
660
|
+
self._available = True
|
|
661
|
+
except Exception:
|
|
662
|
+
self._available = False
|
|
663
|
+
finally:
|
|
664
|
+
for k, v in (("TINA4_AUTO_CACHING", prev_auto), ("TINA4_DB_CACHE", prev_db)):
|
|
665
|
+
if v is None:
|
|
666
|
+
os.environ.pop(k, None)
|
|
667
|
+
else:
|
|
668
|
+
os.environ[k] = v
|
|
669
|
+
|
|
670
|
+
def is_available(self) -> bool:
|
|
671
|
+
return self._available
|
|
672
|
+
|
|
673
|
+
def get(self, key: str):
|
|
674
|
+
row = self._db.fetch_one(
|
|
675
|
+
"SELECT value, expires_at FROM tina4_cache WHERE cache_key = ?", [key])
|
|
676
|
+
if not row:
|
|
677
|
+
self._misses += 1
|
|
678
|
+
return None
|
|
679
|
+
exp = row.get("expires_at")
|
|
680
|
+
if exp and float(exp) > 0 and time.time() > float(exp):
|
|
681
|
+
self._db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
|
|
682
|
+
self._misses += 1
|
|
683
|
+
return None
|
|
684
|
+
self._hits += 1
|
|
685
|
+
try:
|
|
686
|
+
return json.loads(row["value"])
|
|
687
|
+
except (json.JSONDecodeError, TypeError):
|
|
688
|
+
return row["value"]
|
|
689
|
+
|
|
690
|
+
def set(self, key: str, value, ttl: int):
|
|
691
|
+
exp = (time.time() + ttl) if ttl > 0 else 0
|
|
692
|
+
serialized = json.dumps(value, default=str)
|
|
693
|
+
self._db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
|
|
694
|
+
self._db.execute(
|
|
695
|
+
"INSERT INTO tina4_cache (cache_key, value, expires_at) VALUES (?, ?, ?)",
|
|
696
|
+
[key, serialized, exp])
|
|
697
|
+
|
|
698
|
+
def delete(self, key: str) -> bool:
|
|
699
|
+
existed = self._db.fetch_one(
|
|
700
|
+
"SELECT 1 AS x FROM tina4_cache WHERE cache_key = ?", [key]) is not None
|
|
701
|
+
self._db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
|
|
702
|
+
return existed
|
|
703
|
+
|
|
704
|
+
def clear(self):
|
|
705
|
+
self._hits = 0
|
|
706
|
+
self._misses = 0
|
|
707
|
+
self._db.execute("DELETE FROM tina4_cache")
|
|
708
|
+
|
|
709
|
+
def stats(self) -> dict:
|
|
710
|
+
row = self._db.fetch_one("SELECT COUNT(*) AS c FROM tina4_cache")
|
|
711
|
+
size = int(row["c"]) if row and row.get("c") is not None else 0
|
|
712
|
+
return {"hits": self._hits, "misses": self._misses, "size": size, "backend": "database"}
|
|
713
|
+
|
|
714
|
+
def name(self) -> str:
|
|
715
|
+
return "database"
|
|
716
|
+
|
|
717
|
+
|
|
389
718
|
# ── Backend factory ────────────────────────────────────────────────
|
|
390
719
|
|
|
391
720
|
|
|
@@ -395,20 +724,50 @@ def _create_backend(
|
|
|
395
724
|
max_entries: int | None = None,
|
|
396
725
|
cache_dir: str | None = None,
|
|
397
726
|
) -> _CacheBackend:
|
|
398
|
-
"""Create a cache backend from explicit params or env vars.
|
|
727
|
+
"""Create a cache backend from explicit params or env vars.
|
|
728
|
+
|
|
729
|
+
Backends: memory (default), file, redis, valkey, memcached, mongodb, database.
|
|
730
|
+
"""
|
|
399
731
|
backend = backend or os.environ.get("TINA4_CACHE_BACKEND", "memory")
|
|
400
732
|
max_entries = max_entries or int(os.environ.get("TINA4_CACHE_MAX_ENTRIES", "1000"))
|
|
401
733
|
|
|
402
734
|
backend = backend.lower().strip()
|
|
403
735
|
if backend == "redis":
|
|
404
736
|
url = url or os.environ.get("TINA4_CACHE_URL", "redis://localhost:6379")
|
|
405
|
-
|
|
737
|
+
be = _RedisBackend(url=url, max_entries=max_entries)
|
|
738
|
+
elif backend == "valkey":
|
|
739
|
+
url = url or os.environ.get("TINA4_CACHE_URL", "valkey://localhost:6379")
|
|
740
|
+
be = _ValkeyBackend(url=url, max_entries=max_entries)
|
|
741
|
+
elif backend in ("memcached", "memcache"):
|
|
742
|
+
url = url or os.environ.get("TINA4_CACHE_URL", "memcached://localhost:11211")
|
|
743
|
+
be = _MemcachedBackend(url=url, max_entries=max_entries)
|
|
744
|
+
elif backend in ("mongodb", "mongo"):
|
|
745
|
+
url = url or os.environ.get("TINA4_CACHE_URL", "mongodb://localhost:27017")
|
|
746
|
+
be = _MongoBackend(url=url, max_entries=max_entries)
|
|
747
|
+
elif backend in ("database", "db"):
|
|
748
|
+
be = _DatabaseBackend(url=url, max_entries=max_entries)
|
|
406
749
|
elif backend == "file":
|
|
407
750
|
cache_dir = cache_dir or os.environ.get("TINA4_CACHE_DIR", "data/cache")
|
|
408
751
|
return _FileBackend(cache_dir=cache_dir, max_entries=max_entries)
|
|
409
752
|
else:
|
|
410
753
|
return _MemoryBackend(max_entries=max_entries)
|
|
411
754
|
|
|
755
|
+
# Graceful degradation: if the configured backend's driver is missing or the
|
|
756
|
+
# service is unreachable, fall back to the file backend (persistent, zero-dep,
|
|
757
|
+
# always available) rather than silently degrading to a no-op cache.
|
|
758
|
+
if not be.is_available():
|
|
759
|
+
try:
|
|
760
|
+
from tina4_python.debug import Log
|
|
761
|
+
Log.warning(
|
|
762
|
+
f"Cache backend '{backend}' is unavailable "
|
|
763
|
+
f"(driver missing or service unreachable) — falling back to 'file'."
|
|
764
|
+
)
|
|
765
|
+
except Exception:
|
|
766
|
+
pass
|
|
767
|
+
cache_dir = cache_dir or os.environ.get("TINA4_CACHE_DIR", "data/cache")
|
|
768
|
+
return _FileBackend(cache_dir=cache_dir, max_entries=max_entries)
|
|
769
|
+
return be
|
|
770
|
+
|
|
412
771
|
|
|
413
772
|
# ── Cache entry (for response cache) ──────────────────────────────
|
|
414
773
|
|
|
@@ -230,8 +230,40 @@ class Database:
|
|
|
230
230
|
self._cache_hits: int = 0
|
|
231
231
|
self._cache_misses: int = 0
|
|
232
232
|
self._cache_lock = threading.Lock()
|
|
233
|
+
# Persistent mode uses the unified CacheBackend (memory/file/redis/valkey/
|
|
234
|
+
# mongodb/database via TINA4_DB_CACHE_BACKEND) so multiple instances can
|
|
235
|
+
# share one cache with global write-invalidation. Request-scoped mode keeps
|
|
236
|
+
# the in-process dict above (ephemeral, fastest, never serialized).
|
|
237
|
+
self._cache_backend = None
|
|
238
|
+
if self._cache_persistent:
|
|
239
|
+
try:
|
|
240
|
+
from tina4_python.cache import _create_backend
|
|
241
|
+
self._cache_backend = _create_backend(
|
|
242
|
+
backend=os.environ.get("TINA4_DB_CACHE_BACKEND", "memory"),
|
|
243
|
+
url=os.environ.get("TINA4_DB_CACHE_URL"),
|
|
244
|
+
)
|
|
245
|
+
except Exception:
|
|
246
|
+
self._cache_backend = None # fall back to the in-process dict
|
|
233
247
|
Database._instances.add(self)
|
|
234
248
|
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _serialize_result(result) -> dict:
|
|
251
|
+
"""Flatten a DatabaseResult to a JSON-friendly dict for shared backends."""
|
|
252
|
+
return {
|
|
253
|
+
"records": result.records, "count": result.count,
|
|
254
|
+
"limit": result.limit, "offset": result.offset,
|
|
255
|
+
"affected_rows": result.affected_rows, "last_id": result.last_id,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _deserialize_result(data: dict) -> DatabaseResult:
|
|
260
|
+
"""Reconstruct a DatabaseResult from a backend-cached dict."""
|
|
261
|
+
return DatabaseResult(
|
|
262
|
+
records=data.get("records", []), count=data.get("count", 0),
|
|
263
|
+
limit=data.get("limit", 0), offset=data.get("offset", 0),
|
|
264
|
+
affected_rows=data.get("affected_rows", 0), last_id=data.get("last_id"),
|
|
265
|
+
)
|
|
266
|
+
|
|
235
267
|
def _create_adapter(self) -> DatabaseAdapter:
|
|
236
268
|
"""Select adapter based on URL scheme."""
|
|
237
269
|
parsed = urlparse(self.url)
|
|
@@ -324,6 +356,11 @@ class Database:
|
|
|
324
356
|
|
|
325
357
|
def _cache_get(self, key: str):
|
|
326
358
|
"""Return cached result or None if miss/expired."""
|
|
359
|
+
# Persistent mode → shared CacheBackend (serialized DatabaseResult).
|
|
360
|
+
if self._cache_backend is not None:
|
|
361
|
+
raw = self._cache_backend.get(key)
|
|
362
|
+
return self._deserialize_result(raw) if isinstance(raw, dict) else None
|
|
363
|
+
# Request-scoped mode → in-process dict (stores the object directly).
|
|
327
364
|
with self._cache_lock:
|
|
328
365
|
entry = self._query_cache.get(key)
|
|
329
366
|
if entry is None:
|
|
@@ -336,11 +373,17 @@ class Database:
|
|
|
336
373
|
|
|
337
374
|
def _cache_set(self, key: str, result):
|
|
338
375
|
"""Store a result in the cache with TTL."""
|
|
376
|
+
if self._cache_backend is not None:
|
|
377
|
+
self._cache_backend.set(key, self._serialize_result(result), self._cache_ttl)
|
|
378
|
+
return
|
|
339
379
|
with self._cache_lock:
|
|
340
380
|
self._query_cache[key] = (time.monotonic() + self._cache_ttl, result)
|
|
341
381
|
|
|
342
382
|
def _cache_invalidate(self):
|
|
343
383
|
"""Clear the entire query cache (called on writes)."""
|
|
384
|
+
if self._cache_backend is not None:
|
|
385
|
+
self._cache_backend.clear()
|
|
386
|
+
return
|
|
344
387
|
with self._cache_lock:
|
|
345
388
|
self._query_cache.clear()
|
|
346
389
|
|
|
@@ -370,6 +413,17 @@ class Database:
|
|
|
370
413
|
|
|
371
414
|
def cache_stats(self) -> dict:
|
|
372
415
|
"""Return query cache statistics."""
|
|
416
|
+
if self._cache_backend is not None:
|
|
417
|
+
bs = self._cache_backend.stats()
|
|
418
|
+
return {
|
|
419
|
+
"enabled": True,
|
|
420
|
+
"mode": "persistent",
|
|
421
|
+
"hits": self._cache_hits,
|
|
422
|
+
"misses": self._cache_misses,
|
|
423
|
+
"size": bs.get("size", 0),
|
|
424
|
+
"ttl": self._cache_ttl,
|
|
425
|
+
"backend": bs.get("backend", self._cache_backend.name()),
|
|
426
|
+
}
|
|
373
427
|
with self._cache_lock:
|
|
374
428
|
return {
|
|
375
429
|
"enabled": self._cache_enabled,
|
|
@@ -379,6 +433,7 @@ class Database:
|
|
|
379
433
|
"misses": self._cache_misses,
|
|
380
434
|
"size": len(self._query_cache),
|
|
381
435
|
"ttl": self._cache_ttl,
|
|
436
|
+
"backend": "memory",
|
|
382
437
|
}
|
|
383
438
|
|
|
384
439
|
def cache_clear(self):
|
|
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
|
|
File without changes
|