tina4-python 3.13.75__tar.gz → 3.13.77__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. {tina4_python-3.13.75 → tina4_python-3.13.77}/PKG-INFO +1 -1
  2. {tina4_python-3.13.75 → tina4_python-3.13.77}/pyproject.toml +4 -1
  3. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/server.py +60 -26
  4. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/migration/runner.py +33 -11
  5. {tina4_python-3.13.75 → tina4_python-3.13.77}/.gitignore +0 -0
  6. {tina4_python-3.13.75 → tina4_python-3.13.77}/README.md +0 -0
  7. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/CLAUDE.md +0 -0
  8. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/HtmlElement.py +0 -0
  9. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/Testing.py +0 -0
  10. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/__init__.py +0 -0
  11. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/ai/__init__.py +0 -0
  12. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/api/__init__.py +0 -0
  13. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/auth/__init__.py +0 -0
  14. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/cache/__init__.py +0 -0
  15. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/cli/__init__.py +0 -0
  16. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/container/__init__.py +0 -0
  17. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/context/__init__.py +0 -0
  18. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/context/chunker.py +0 -0
  19. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/__init__.py +0 -0
  20. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/cache.py +0 -0
  21. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/constants.py +0 -0
  22. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/events.py +0 -0
  23. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/middleware.py +0 -0
  24. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/rate_limiter.py +0 -0
  25. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/request.py +0 -0
  26. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/response.py +0 -0
  27. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/core/router.py +0 -0
  28. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/connection.py +0 -0
  32. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/firebird.py +0 -0
  33. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/mongodb.py +0 -0
  34. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/mssql.py +0 -0
  35. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/mysql.py +0 -0
  36. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/postgres.py +0 -0
  38. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/database/sqlite.py +0 -0
  39. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/debug/__init__.py +0 -0
  40. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/debug/error_overlay.py +0 -0
  41. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/dev_admin/__init__.py +0 -0
  42. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/dev_admin/metrics.py +0 -0
  43. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/dev_admin/plan.py +0 -0
  44. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/dev_admin/project_index.py +0 -0
  45. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/docs.py +0 -0
  46. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/docstore/__init__.py +0 -0
  47. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/dotenv/__init__.py +0 -0
  48. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/env.py +0 -0
  49. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/frond/FROND.md +0 -0
  50. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/frond/__init__.py +0 -0
  51. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/frond/engine.py +0 -0
  52. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/auth/meta.json +0 -0
  53. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  54. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/database/meta.json +0 -0
  55. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  56. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/error-overlay/meta.json +0 -0
  57. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  58. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/orm/meta.json +0 -0
  59. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  60. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  61. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/queue/meta.json +0 -0
  62. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  63. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/rest-api/meta.json +0 -0
  64. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  65. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/templates/meta.json +0 -0
  66. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  67. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  68. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/graphql/__init__.py +0 -0
  69. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/i18n/__init__.py +0 -0
  70. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/mcp/__init__.py +0 -0
  71. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/mcp/protocol.py +0 -0
  72. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/mcp/tools.py +0 -0
  73. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/messenger/__init__.py +0 -0
  74. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/migration/__init__.py +0 -0
  75. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/orm/__init__.py +0 -0
  76. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/orm/fields.py +0 -0
  77. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/orm/model.py +0 -0
  78. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/__feedback/widget.js +0 -0
  79. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/css/tina4.css +0 -0
  80. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/css/tina4.min.css +0 -0
  81. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/favicon.ico +0 -0
  82. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/images/logo.svg +0 -0
  83. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  84. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/js/frond.js +0 -0
  85. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/js/frond.min.js +0 -0
  86. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  87. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  88. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/js/tina4.min.js +0 -0
  89. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/js/tina4js.min.js +0 -0
  90. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/swagger/index.html +0 -0
  91. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  92. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/query_builder/__init__.py +0 -0
  93. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue/__init__.py +0 -0
  94. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue/job.py +0 -0
  95. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue/kafka_backend.py +0 -0
  96. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue/lite_backend.py +0 -0
  97. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue/mongo_backend.py +0 -0
  98. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue/rabbitmq_backend.py +0 -0
  99. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue_backends/__init__.py +0 -0
  100. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue_backends/kafka_backend.py +0 -0
  101. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue_backends/mongo_backend.py +0 -0
  102. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  103. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/__init__.py +0 -0
  104. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/models/Attachment.py +0 -0
  105. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/models/Channel.py +0 -0
  106. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/models/ChannelMember.py +0 -0
  107. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/models/Message.py +0 -0
  108. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/models/Workspace.py +0 -0
  109. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/models/__init__.py +0 -0
  110. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/realtime/storage.py +0 -0
  111. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/__init__.py +0 -0
  112. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  113. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_badges.scss +0 -0
  114. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  115. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_cards.scss +0 -0
  116. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_forms.scss +0 -0
  117. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_grid.scss +0 -0
  118. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_modals.scss +0 -0
  119. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_nav.scss +0 -0
  120. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_reset.scss +0 -0
  121. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_tables.scss +0 -0
  122. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_typography.scss +0 -0
  123. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  124. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/_variables.scss +0 -0
  125. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/base.scss +0 -0
  126. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/colors.scss +0 -0
  127. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/scss/tina4css/tina4.scss +0 -0
  128. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/seeder/__init__.py +0 -0
  129. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/service/__init__.py +0 -0
  130. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/session/__init__.py +0 -0
  131. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/session_handlers/__init__.py +0 -0
  132. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  133. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/session_handlers/redis_handler.py +0 -0
  134. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/session_handlers/valkey_handler.py +0 -0
  135. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/swagger/__init__.py +0 -0
  136. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/components/crud.twig +0 -0
  137. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  138. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  139. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/docker/python/Dockerfile +0 -0
  140. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  141. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/302.twig +0 -0
  142. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/401.twig +0 -0
  143. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/403.twig +0 -0
  144. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/404.twig +0 -0
  145. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/500.twig +0 -0
  146. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/502.twig +0 -0
  147. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/503.twig +0 -0
  148. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/errors/base.twig +0 -0
  149. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/frontend/README.md +0 -0
  150. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/templates/readme.md +0 -0
  151. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/test/__init__.py +0 -0
  152. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/test_client/__init__.py +0 -0
  153. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  156. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  157. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  158. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  159. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  160. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  161. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/validator/__init__.py +0 -0
  166. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/websocket/__init__.py +0 -0
  167. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/websocket/backplane.py +0 -0
  168. {tina4_python-3.13.75 → tina4_python-3.13.77}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.75
3
+ Version: 3.13.77
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.75"
3
+ version = "3.13.77"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -64,6 +64,9 @@ tina4python = "tina4_python.cli:main"
64
64
  [tool.pytest.ini_options]
65
65
  asyncio_mode = "auto"
66
66
  testpaths = ["tests"]
67
+ markers = [
68
+ "slow: a test that needs real wall-clock time to reproduce a timing bug (deselect with -m 'not slow')",
69
+ ]
67
70
 
68
71
  [tool.ruff]
69
72
  target-version = "py312"
@@ -49,6 +49,58 @@ def background(callback, interval: float = 1.0):
49
49
  _background_tasks.append({"callback": callback, "interval": interval})
50
50
 
51
51
 
52
+ async def background_tick_loop(callback, interval: float, executor, shutdown):
53
+ """Run one registered background task on its interval until shutdown.
54
+
55
+ A task NEVER overlaps itself: each run is awaited to completion before the
56
+ interval sleep and the next tick. That is the only sound reading of an
57
+ interval scheduler, and it is what makes the one-worker-per-task pool sizing
58
+ in `_serve` safe.
59
+
60
+ Args:
61
+ callback: The registered callable (sync or async, no arguments).
62
+ interval: Seconds to sleep between runs.
63
+ executor: ThreadPoolExecutor used for sync callbacks.
64
+ shutdown: asyncio.Event that ends the loop when set.
65
+ """
66
+ timeout = max(interval * 2, 5.0)
67
+ loop = asyncio.get_running_loop()
68
+ while not shutdown.is_set():
69
+ try:
70
+ if asyncio.iscoroutinefunction(callback):
71
+ # A coroutine IS cancellable, so the timeout really interrupts it.
72
+ await asyncio.wait_for(callback(), timeout=timeout)
73
+ else:
74
+ # Run sync callback in a thread pool — never blocks the event loop.
75
+ #
76
+ # A callable already running in a ThreadPoolExecutor CANNOT be
77
+ # cancelled: Future.cancel() returns False once it has started and
78
+ # there is no way to interrupt the thread. So this must NOT use
79
+ # wait_for() — that abandons the wrapper future while the thread
80
+ # keeps running, and the next tick then starts a SECOND copy
81
+ # alongside the first (silent double-execution of a slow sweep,
82
+ # with every later tick piling on another). Wait for the run to
83
+ # finish and only WARN about the overrun.
84
+ fut = loop.run_in_executor(executor, callback)
85
+ done, _pending = await asyncio.wait({fut}, timeout=timeout)
86
+ if not done:
87
+ Log.warning(
88
+ f"Background task is still running after {timeout:.1f}s. "
89
+ f"The next run is deferred until it finishes (a running sync "
90
+ f"task cannot be interrupted). Use non-blocking calls "
91
+ f"(e.g. queue.pop() instead of queue.consume())."
92
+ )
93
+ await fut # never overlap; re-raises the callback's error
94
+ except asyncio.TimeoutError:
95
+ Log.warning(
96
+ f"Background task exceeded {timeout:.1f}s timeout and was interrupted. "
97
+ f"Use non-blocking calls (e.g. queue.pop() instead of queue.consume())."
98
+ )
99
+ except Exception as e:
100
+ Log.error(f"Background task error: {e}")
101
+ await asyncio.sleep(interval)
102
+
103
+
52
104
  # module_name → source-file mtime at the last (re)import. Drives the
53
105
  # changed-file detection in ``_auto_discover``: a file whose mtime is newer
54
106
  # than the recorded value is re-executed in place, so editing an existing
@@ -2743,7 +2795,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
2743
2795
 
2744
2796
  # Start registered background tasks as asyncio tasks.
2745
2797
  # Sync callbacks run in a thread pool so they CANNOT block the event loop.
2746
- # A timeout (2x interval, min 5s) cancels runaway tasks.
2798
+ # max_workers is one per registered task sound only because a task never
2799
+ # overlaps itself (see background_tick_loop), so tasks cannot starve each
2800
+ # other of workers.
2747
2801
  import concurrent.futures
2748
2802
  _executor = concurrent.futures.ThreadPoolExecutor(
2749
2803
  max_workers=max(len(_background_tasks), 2),
@@ -2751,31 +2805,11 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
2751
2805
  )
2752
2806
  bg_tasks = []
2753
2807
  for task_def in _background_tasks:
2754
- cb = task_def["callback"]
2755
- iv = task_def["interval"]
2756
-
2757
- async def _tick_loop(_cb=cb, _iv=iv):
2758
- timeout = max(_iv * 2, 5.0)
2759
- while not shutdown.is_set():
2760
- try:
2761
- if asyncio.iscoroutinefunction(_cb):
2762
- await asyncio.wait_for(_cb(), timeout=timeout)
2763
- else:
2764
- # Run sync callback in thread pool — never blocks the event loop
2765
- await asyncio.wait_for(
2766
- loop.run_in_executor(_executor, _cb),
2767
- timeout=timeout,
2768
- )
2769
- except asyncio.TimeoutError:
2770
- Log.warning(
2771
- f"Background task exceeded {timeout:.1f}s timeout and was interrupted. "
2772
- f"Use non-blocking calls (e.g. queue.pop() instead of queue.consume())."
2773
- )
2774
- except Exception as e:
2775
- Log.error(f"Background task error: {e}")
2776
- await asyncio.sleep(_iv)
2777
-
2778
- bg_tasks.append(asyncio.create_task(_tick_loop()))
2808
+ bg_tasks.append(asyncio.create_task(
2809
+ background_tick_loop(
2810
+ task_def["callback"], task_def["interval"], _executor, shutdown,
2811
+ )
2812
+ ))
2779
2813
 
2780
2814
  await shutdown.wait()
2781
2815
 
@@ -296,7 +296,11 @@ def _ensure_tracking_table(db, migration_folder: str | None = None):
296
296
  # and copy the values across so already-applied migrations are still seen as
297
297
  # applied — without this the `migration_name`-based read below finds nothing
298
298
  # and every migration re-runs. The old `migration_id` column is left in
299
- # place (harmless, ignored from now on).
299
+ # place: it cannot be dropped portably (SQLite cannot drop a NOT NULL or a
300
+ # column without a full table rebuild). It is ignored on READS, but it is
301
+ # NOT ignorable on WRITES — it is NOT NULL on that table, so
302
+ # `_record_applied` must keep populating it (see there). Leaving it unwritten
303
+ # is what broke every migration on an upgraded database in 3.13.55-3.13.75.
300
304
  if "migration_id" in cols and "migration_name" not in cols:
301
305
  col_type = "VARCHAR(500)" if _is_firebird(db) else "TEXT"
302
306
  try:
@@ -641,18 +645,36 @@ def _record_applied(db, name: str, desc: str, batch: int, passed: int = 1) -> No
641
645
  """
642
646
  now = datetime.now(timezone.utc).isoformat()
643
647
  db.execute("DELETE FROM tina4_migration WHERE migration_name = ?", [name])
648
+
649
+ # Build the column list from the columns that ACTUALLY exist on this table,
650
+ # so a legacy column an earlier in-place upgrade left behind is populated
651
+ # rather than defaulted to NULL. A table created by v3 <= 3.13.54 carries
652
+ # `migration_id NOT NULL UNIQUE`; the rename upgrade adds `migration_name`
653
+ # beside it but cannot drop the NOT NULL portably (SQLite cannot drop it at
654
+ # all without a table rebuild), so this insert has to satisfy it or every
655
+ # migration on that database fails and none can ever apply.
656
+ cols = _column_names(db)
657
+ insert_cols = ["migration_name", "description", "batch", "executed_at", "passed"]
658
+ values = [name, desc, batch, now, passed]
659
+
660
+ # v3 <= 3.13.54: `migration_id` held exactly the value `migration_name` holds
661
+ # now, so writing `name` restores that invariant. Safe against its UNIQUE:
662
+ # the DELETE above removed this migration's own row first, so a re-run
663
+ # replaces its row instead of colliding.
664
+ if "migration_id" in cols:
665
+ insert_cols.append("migration_id")
666
+ values.append(name)
667
+
644
668
  if _is_firebird(db):
645
669
  row = db.fetch_one("SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS next_id FROM RDB$DATABASE")
646
- next_id = row["next_id"] if row else 1
647
- db.execute(
648
- "INSERT INTO tina4_migration (id, migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?, ?)",
649
- [next_id, name, desc, batch, now, passed],
650
- )
651
- else:
652
- db.execute(
653
- "INSERT INTO tina4_migration (migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?)",
654
- [name, desc, batch, now, passed],
655
- )
670
+ insert_cols.insert(0, "id")
671
+ values.insert(0, row["next_id"] if row else 1)
672
+
673
+ placeholders = ", ".join("?" for _ in insert_cols)
674
+ db.execute(
675
+ f"INSERT INTO tina4_migration ({', '.join(insert_cols)}) VALUES ({placeholders})",
676
+ values,
677
+ )
656
678
 
657
679
 
658
680
  def _migrate(db, migration_folder: str = "migrations", delimiter: str = ";") -> list[str]:
File without changes