pydocket 0.26.2__tar.gz → 0.27.0__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 (219) hide show
  1. {pydocket-0.26.2 → pydocket-0.27.0}/PKG-INFO +2 -11
  2. {pydocket-0.26.2 → pydocket-0.27.0}/pyproject.toml +2 -2
  3. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_execution_scripts.py +2 -1
  4. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/cli/__init__.py +13 -0
  5. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_perpetual.py +40 -17
  6. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_retry.py +38 -5
  7. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/docket.py +68 -61
  8. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/execution.py +13 -3
  9. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/cancel_task.lua +12 -0
  10. pydocket-0.27.0/src/docket/lua/clear.lua +95 -0
  11. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/schedule.lua +9 -10
  12. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/terminal.lua +10 -2
  13. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/worker.py +38 -20
  14. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_worker.py +42 -0
  15. pydocket-0.27.0/tests/cli/worker_dependencies.py +31 -0
  16. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_waiter_queue.py +2 -0
  17. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_cron.py +61 -1
  18. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_automatic_perpetual_recovery.py +40 -0
  19. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_dependencies_core.py +2 -0
  20. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_clear.py +133 -0
  21. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_get_result_cancellation.py +32 -0
  22. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_immediate_retry_state.py +2 -0
  23. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_payload_escaping.py +2 -0
  24. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_reliability.py +5 -13
  25. pydocket-0.27.0/tests/test_perpetual_reschedule.py +209 -0
  26. pydocket-0.27.0/tests/test_retry_replaced.py +146 -0
  27. pydocket-0.27.0/tests/worker/test_run_dependencies.py +119 -0
  28. pydocket-0.26.2/tests/test_perpetual_reschedule.py +0 -97
  29. {pydocket-0.26.2 → pydocket-0.27.0}/.coveragerc-cli +0 -0
  30. {pydocket-0.26.2 → pydocket-0.27.0}/.coveragerc-core +0 -0
  31. {pydocket-0.26.2 → pydocket-0.27.0}/.gitignore +0 -0
  32. {pydocket-0.26.2 → pydocket-0.27.0}/.pre-commit-config.yaml +0 -0
  33. {pydocket-0.26.2 → pydocket-0.27.0}/LICENSE +0 -0
  34. {pydocket-0.26.2 → pydocket-0.27.0}/README.md +0 -0
  35. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/__init__.py +0 -0
  36. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/__main__.py +0 -0
  37. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_cancellation.py +0 -0
  38. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_docket_snapshot.py +0 -0
  39. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_execution_progress.py +0 -0
  40. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_lua.py +0 -0
  41. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_prometheus_exporter.py +0 -0
  42. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redelivery.py +0 -0
  43. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis.py +0 -0
  44. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis_credentials.py +0 -0
  45. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis_memory.py +0 -0
  46. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis_sentinel.py +0 -0
  47. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_result_store.py +0 -0
  48. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_telemetry.py +0 -0
  49. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_uuid7.py +0 -0
  50. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/agenda.py +0 -0
  51. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/annotations.py +0 -0
  52. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/cli/_support.py +0 -0
  53. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/__init__.py +0 -0
  54. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_base.py +0 -0
  55. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_concurrency.py +0 -0
  56. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_contextual.py +0 -0
  57. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_cooldown.py +0 -0
  58. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_cron.py +0 -0
  59. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_debounce.py +0 -0
  60. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_functional.py +0 -0
  61. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_progress.py +0 -0
  62. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_ratelimit.py +0 -0
  63. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_resolution.py +0 -0
  64. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_timeout.py +0 -0
  65. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/instrumentation.py +0 -0
  66. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/acquire_or_park.lua +0 -0
  67. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/cancel_cleanup.lua +0 -0
  68. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/claim.lua +0 -0
  69. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/debounce.lua +0 -0
  70. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/progress_write.lua +0 -0
  71. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/ratelimit.lua +0 -0
  72. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/refresh_lease.lua +0 -0
  73. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/release_and_wake.lua +0 -0
  74. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/scavenge_and_wake.lua +0 -0
  75. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/stream_due_tasks.lua +0 -0
  76. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/py.typed +0 -0
  77. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/strikelist.py +0 -0
  78. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/tasks.py +0 -0
  79. {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/testing.py +0 -0
  80. {pydocket-0.26.2 → pydocket-0.27.0}/tests/__init__.py +0 -0
  81. {pydocket-0.26.2 → pydocket-0.27.0}/tests/_container.py +0 -0
  82. {pydocket-0.26.2 → pydocket-0.27.0}/tests/_key_leak_checker.py +0 -0
  83. {pydocket-0.26.2 → pydocket-0.27.0}/tests/_rotating_provider.py +0 -0
  84. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/__init__.py +0 -0
  85. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/run.py +0 -0
  86. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_clear.py +0 -0
  87. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_iterate_with_timeout.py +0 -0
  88. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_module.py +0 -0
  89. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_parsing.py +0 -0
  90. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_snapshot.py +0 -0
  91. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_striking.py +0 -0
  92. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_tasks.py +0 -0
  93. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_url_validation.py +0 -0
  94. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_version.py +0 -0
  95. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_watch.py +0 -0
  96. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_workers.py +0 -0
  97. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/waiting.py +0 -0
  98. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cluster/Dockerfile +0 -0
  99. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cluster/entrypoint.sh +0 -0
  100. {pydocket-0.26.2 → pydocket-0.27.0}/tests/cluster/start-cluster.sh +0 -0
  101. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/__init__.py +0 -0
  102. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/overlap.py +0 -0
  103. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_annotated.py +0 -0
  104. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_basic.py +0 -0
  105. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_errors_and_resilience.py +0 -0
  106. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_execution_patterns.py +0 -0
  107. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_parked_status.py +0 -0
  108. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_redelivery.py +0 -0
  109. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_waiter_safeguard.py +0 -0
  110. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_worker_mechanics.py +0 -0
  111. {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/waiters.py +0 -0
  112. {pydocket-0.26.2 → pydocket-0.27.0}/tests/conftest.py +0 -0
  113. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/AGENTS.md +0 -0
  114. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/CLAUDE.md +0 -0
  115. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/conftest.py +0 -0
  116. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_async_dependencies.py +0 -0
  117. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_builtin_tasks.py +0 -0
  118. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_call_arguments.py +0 -0
  119. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_cancellation.py +0 -0
  120. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_context_injection.py +0 -0
  121. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_errors.py +0 -0
  122. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_idempotency.py +0 -0
  123. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_logging.py +0 -0
  124. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_perpetual.py +0 -0
  125. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_progress_state.py +0 -0
  126. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_results.py +0 -0
  127. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_retries.py +0 -0
  128. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_scheduling.py +0 -0
  129. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_self_perpetuation.py +0 -0
  130. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_shared_dependencies.py +0 -0
  131. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_striking.py +0 -0
  132. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_sync_dependencies.py +0 -0
  133. {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_timeouts.py +0 -0
  134. {pydocket-0.26.2 → pydocket-0.27.0}/tests/instrumentation/test_counters.py +0 -0
  135. {pydocket-0.26.2 → pydocket-0.27.0}/tests/instrumentation/test_export.py +0 -0
  136. {pydocket-0.26.2 → pydocket-0.27.0}/tests/instrumentation/test_tracing.py +0 -0
  137. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/calc.lua +0 -0
  138. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/count_argv.lua +0 -0
  139. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_bool.lua +0 -0
  140. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_described_key.lua +0 -0
  141. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_dict.lua +0 -0
  142. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_keys_and_args.lua +0 -0
  143. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_list.lua +0 -0
  144. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_numbers.lua +0 -0
  145. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_tuple.lua +0 -0
  146. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/extra_binding.lua +0 -0
  147. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/join_variadic.lua +0 -0
  148. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/misdecoded_flag.lua +0 -0
  149. {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/noscript_echo.lua +0 -0
  150. {pydocket-0.26.2 → pydocket-0.27.0}/tests/sitecustomize.py +0 -0
  151. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_agenda.py +0 -0
  152. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_annotated_dependencies.py +0 -0
  153. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_batch_scheduling.py +0 -0
  154. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_call_arguments.py +0 -0
  155. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_cancel_queued_task.py +0 -0
  156. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_cancellation.py +0 -0
  157. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_claim_supersedes.py +0 -0
  158. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_cooldown.py +0 -0
  159. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_credential_provider.py +0 -0
  160. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_credential_rotation.py +0 -0
  161. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_debounce.py +0 -0
  162. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_dependencies_advanced.py +0 -0
  163. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_dependency_uniqueness.py +0 -0
  164. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_depends_typing.py +0 -0
  165. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_execution.py +0 -0
  166. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_isolation.py +0 -0
  167. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_keys.py +0 -0
  168. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_registration.py +0 -0
  169. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_snapshot.py +0 -0
  170. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_execution.py +0 -0
  171. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_execution_state.py +0 -0
  172. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_exponential_retry_overflow.py +0 -0
  173. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_fallback_task.py +0 -0
  174. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_handler_semantics.py +0 -0
  175. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_interrupted_tasks.py +0 -0
  176. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_key_leak_protection.py +0 -0
  177. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_lease_renewal.py +0 -0
  178. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_lua_decorator.py +0 -0
  179. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_memory_backend.py +0 -0
  180. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_handoff.py +0 -0
  181. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_race.py +0 -0
  182. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_state.py +0 -0
  183. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_progress_basics.py +0 -0
  184. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_progress_clobber.py +0 -0
  185. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_progress_pubsub.py +0 -0
  186. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_pubsub_resp_version.py +0 -0
  187. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_ratelimit.py +0 -0
  188. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_redelivery.py +0 -0
  189. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_redelivery_sweep.py +0 -0
  190. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_redis_connection.py +0 -0
  191. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_reschedule_clears_worker.py +0 -0
  192. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_results_retrieval.py +0 -0
  193. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_results_storage.py +0 -0
  194. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_reused_keys.py +0 -0
  195. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_sentinel_urls.py +0 -0
  196. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_socket_timeout.py +0 -0
  197. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_strikelist.py +0 -0
  198. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_striking.py +0 -0
  199. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_task_cycle_round_trips.py +0 -0
  200. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_terminal_acked.py +0 -0
  201. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_testing.py +0 -0
  202. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_uncalled_for_contract.py +0 -0
  203. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_uuid7.py +0 -0
  204. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_cancellation.py +0 -0
  205. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_dependencies.py +0 -0
  206. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_single_dependencies.py +0 -0
  207. {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_task_deps_interaction.py +0 -0
  208. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_bootstrap.py +0 -0
  209. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_core.py +0 -0
  210. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_crash.py +0 -0
  211. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_delivery_batch.py +0 -0
  212. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_invariants.py +0 -0
  213. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_lifecycle.py +0 -0
  214. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_liveness.py +0 -0
  215. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_reconnection.py +0 -0
  216. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_scheduling.py +0 -0
  217. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_strikes_at_startup.py +0 -0
  218. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_task_factory.py +0 -0
  219. {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_ttl_zero.py +0 -0
@@ -1,24 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pydocket
3
- Version: 0.26.2
3
+ Version: 0.27.0
4
4
  Summary: A distributed background task system for Python functions
5
5
  Project-URL: Homepage, https://docket.lol/
6
6
  Project-URL: Documentation, https://docket.lol/en/latest/
7
7
  Project-URL: Repository, https://github.com/chrisguidry/docket
8
8
  Project-URL: Bug Tracker, https://github.com/chrisguidry/docket/issues
9
9
  Author-email: Chris Guidry <guid@omg.lol>
10
- License: # Released under MIT License
11
-
12
- Copyright (c) 2025 Chris Guidry.
13
-
14
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
15
-
16
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
17
-
18
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10
+ License-Expression: MIT
19
11
  License-File: LICENSE
20
12
  Classifier: Development Status :: 4 - Beta
21
- Classifier: License :: OSI Approved :: MIT License
22
13
  Classifier: Operating System :: OS Independent
23
14
  Classifier: Programming Language :: Python :: 3
24
15
  Classifier: Programming Language :: Python :: 3.10
@@ -8,7 +8,8 @@ dynamic = ["version"]
8
8
  description = "A distributed background task system for Python functions"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.10"
11
- license = { file = "LICENSE" }
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
12
13
  authors = [{ name = "Chris Guidry", email = "guid@omg.lol" }]
13
14
  classifiers = [
14
15
  "Development Status :: 4 - Beta",
@@ -18,7 +19,6 @@ classifiers = [
18
19
  "Programming Language :: Python :: 3.12",
19
20
  "Programming Language :: Python :: 3.13",
20
21
  "Programming Language :: Python :: 3.14",
21
- "License :: OSI Approved :: MIT License",
22
22
  "Operating System :: OS Independent",
23
23
  "Typing :: Typed",
24
24
  ]
@@ -94,4 +94,5 @@ async def _cancel_task(
94
94
  task_key: Arg[str],
95
95
  completed_at: Arg[str],
96
96
  state_payload: Arg[str],
97
- ) -> bytes: ...
97
+ expected_generation: Arg[int],
98
+ ) -> bytes | str: ...
@@ -225,6 +225,18 @@ def worker(
225
225
  envvar="DOCKET_FALLBACK_TASK",
226
226
  ),
227
227
  ] = None,
228
+ dependencies: Annotated[
229
+ str | None,
230
+ typer.Option(
231
+ "--dependencies",
232
+ help=(
233
+ "Path to a worker dependency collection (module:member). "
234
+ "The collection is a mapping or sequence of Dependency instances "
235
+ "(e.g., Depends(fn)) that run around every task."
236
+ ),
237
+ envvar="DOCKET_WORKER_DEPENDENCIES",
238
+ ),
239
+ ] = None,
228
240
  ) -> None:
229
241
  asyncio.run(
230
242
  Worker.run(
@@ -244,6 +256,7 @@ def worker(
244
256
  metrics_port=metrics_port,
245
257
  tasks=tasks,
246
258
  fallback_task=fallback_task,
259
+ dependencies=dependencies,
247
260
  )
248
261
  )
249
262
 
@@ -20,7 +20,7 @@ if TYPE_CHECKING: # pragma: no cover
20
20
  from ..docket import Docket
21
21
  from ..execution import Execution
22
22
 
23
- from ..execution import Disposition
23
+ from ..execution import Disposition, ExecutionState
24
24
  from ..instrumentation import TASKS_PERPETUATED, TASKS_SUPERSEDED
25
25
 
26
26
  logger = logging.getLogger("docket.dependencies")
@@ -114,13 +114,20 @@ class Perpetual(CompletionHandler["Perpetual"]):
114
114
 
115
115
  async def on_complete(self, execution: Execution, outcome: TaskOutcome) -> bool:
116
116
  """Handle completion by scheduling the next execution."""
117
+ docket = current_docket.get()
118
+
117
119
  if self.cancelled:
118
- docket = current_docket.get()
120
+ # Cancel under this attempt's generation, so a replace made while
121
+ # it ran survives the stop.
119
122
  async with docket.redis() as redis:
120
- await docket._cancel(redis, execution.key)
123
+ cancelled = await docket._cancel(
124
+ redis, execution.key, execution.generation
125
+ )
126
+ if not cancelled:
127
+ await self._end_superseded(execution, outcome)
128
+ return True
121
129
  return False
122
130
 
123
- docket = current_docket.get()
124
131
  worker = current_worker.get()
125
132
 
126
133
  if self._next_when:
@@ -140,19 +147,7 @@ class Perpetual(CompletionHandler["Perpetual"]):
140
147
  )
141
148
 
142
149
  if successor.disposition is Disposition.SUPERSEDED:
143
- TASKS_SUPERSEDED.add(
144
- 1,
145
- {
146
- **worker.labels(),
147
- **execution.general_labels(),
148
- "docket.where": "on_complete",
149
- },
150
- )
151
- logger.info(
152
- "↬ [%s] %s (superseded)",
153
- format_duration(outcome.duration.total_seconds()),
154
- execution.call_repr(),
155
- )
150
+ await self._end_superseded(execution, outcome)
156
151
  return True
157
152
 
158
153
  TASKS_PERPETUATED.add(1, {**worker.labels(), **execution.general_labels()})
@@ -172,3 +167,31 @@ class Perpetual(CompletionHandler["Perpetual"]):
172
167
  )
173
168
 
174
169
  return True
170
+
171
+ async def _end_superseded(self, execution: Execution, outcome: TaskOutcome) -> None:
172
+ """Ends a run whose stop or reschedule gave way to a replace. It
173
+ publishes no state, so a caller waiting on the key waits for the
174
+ replacement instead of taking this run as its end."""
175
+ worker = current_worker.get()
176
+ TASKS_SUPERSEDED.add(
177
+ 1,
178
+ {
179
+ **worker.labels(),
180
+ **execution.general_labels(),
181
+ "docket.where": "on_complete",
182
+ },
183
+ )
184
+ logger.info(
185
+ "↬ [%s] %s (superseded)",
186
+ format_duration(outcome.duration.total_seconds()),
187
+ execution.call_repr(),
188
+ )
189
+ exception = outcome.exception
190
+ if exception is None:
191
+ await execution._mark_as_terminal(ExecutionState.COMPLETED, publish=False)
192
+ else:
193
+ await execution._mark_as_terminal(
194
+ ExecutionState.FAILED,
195
+ error=f"{type(exception).__name__}: {exception}",
196
+ publish=False,
197
+ )
@@ -17,7 +17,8 @@ from ._base import (
17
17
  if TYPE_CHECKING: # pragma: no cover
18
18
  from ..execution import Execution
19
19
 
20
- from ..instrumentation import TASKS_RETRIED
20
+ from ..execution import Disposition, ExecutionState
21
+ from ..instrumentation import TASKS_RETRIED, TASKS_SUPERSEDED
21
22
 
22
23
  logger = logging.getLogger("docket.dependencies")
23
24
 
@@ -105,12 +106,16 @@ class Retry(FailureHandler["Retry"]):
105
106
  # Pass both replace=True and the original message_id. The reschedule
106
107
  # branch handles the ACK+XDEL of the failed message when message_id is
107
108
  # set; replace=True keeps correct behavior in the (otherwise
108
- # impossible) case where message_id is None.
109
- await execution.schedule(replace=True, reschedule_message=execution.message_id)
109
+ # impossible) case where message_id is None. The retry carries this
110
+ # attempt's generation, so a replace made while it ran keeps its own
111
+ # time and arguments instead of giving way to the retry.
112
+ disposition = await execution.schedule(
113
+ replace=True,
114
+ reschedule_message=execution.message_id,
115
+ expected_generation=execution.generation,
116
+ )
110
117
 
111
118
  worker = current_worker.get()
112
- TASKS_RETRIED.add(1, {**worker.labels(), **execution.general_labels()})
113
-
114
119
  logger.error(
115
120
  "↩ [%s] %s",
116
121
  format_duration(outcome.duration.total_seconds()),
@@ -118,6 +123,34 @@ class Retry(FailureHandler["Retry"]):
118
123
  exc_info=outcome.exception,
119
124
  )
120
125
 
126
+ if disposition is Disposition.SUPERSEDED:
127
+ TASKS_SUPERSEDED.add(
128
+ 1,
129
+ {
130
+ **worker.labels(),
131
+ **execution.general_labels(),
132
+ "docket.where": "retry",
133
+ },
134
+ )
135
+ logger.info(
136
+ "↬ [%s] %s (superseded)",
137
+ format_duration(outcome.duration.total_seconds()),
138
+ execution.call_repr(),
139
+ )
140
+ # The schedule left the failed delivery pending; ending it as
141
+ # failed acknowledges it without touching the newer run's state.
142
+ # It publishes no state, so a caller waiting on the key waits for
143
+ # the replacement instead of taking this failure as its end.
144
+ exception = outcome.exception
145
+ await execution._mark_as_terminal(
146
+ ExecutionState.FAILED,
147
+ error=f"{type(exception).__name__}: {exception}",
148
+ publish=False,
149
+ )
150
+ return True
151
+
152
+ TASKS_RETRIED.add(1, {**worker.labels(), **execution.general_labels()})
153
+
121
154
  logger.info(
122
155
  "↫ [%s] %s",
123
156
  format_duration(outcome.duration.total_seconds()),
@@ -27,6 +27,7 @@ from ._docket_snapshot import DocketSnapshotMixin
27
27
  from ._docket_snapshot import RunningExecution as RunningExecution
28
28
  from ._docket_snapshot import WorkerInfo as WorkerInfo
29
29
  from ._execution_scripts import _cancel_task
30
+ from ._lua import Arg, Key, redis_script
30
31
  from ._redis import (
31
32
  PubSubClient,
32
33
  RedisClient,
@@ -68,6 +69,24 @@ logger: logging.Logger = logging.getLogger(__name__)
68
69
  tracer: trace.Tracer = trace.get_tracer(__name__)
69
70
 
70
71
 
72
+ @redis_script
73
+ async def _clear(
74
+ redis: RedisClient,
75
+ *,
76
+ stream_key: Key[str],
77
+ queue_key: Key[str],
78
+ docket_prefix: Arg[str],
79
+ completed_at: Arg[str],
80
+ ttl_seconds: Arg[int],
81
+ batch: Arg[int],
82
+ ) -> int: ...
83
+
84
+
85
+ # How many queue entries and stream messages one clear.lua call removes, so
86
+ # that no one call blocks Redis for long.
87
+ CLEAR_BATCH = 1000
88
+
89
+
71
90
  P = ParamSpec("P")
72
91
  R = TypeVar("R")
73
92
 
@@ -912,7 +931,9 @@ class Docket(DocketSnapshotMixin):
912
931
  if "BUSYGROUP" not in str(e):
913
932
  raise # pragma: no cover
914
933
 
915
- async def _cancel(self, redis: RedisClient, key: str) -> None:
934
+ async def _cancel(
935
+ self, redis: RedisClient, key: str, expected_generation: int = 0
936
+ ) -> bool:
916
937
  """Cancel a task atomically.
917
938
 
918
939
  Handles cancellation regardless of task location:
@@ -925,6 +946,17 @@ class Docket(DocketSnapshotMixin):
925
946
  waiter streams) clean up via the cancel pub/sub channel
926
947
  published by ``Docket.cancel`` -- Docket itself stays unaware of any
927
948
  dependency-specific storage.
949
+
950
+ Args:
951
+ redis: The connection to run the cancel on.
952
+ key: The key of the task to cancel.
953
+ expected_generation: The generation the caller believes holds
954
+ ``key``. When it is non-zero and Redis holds a newer one,
955
+ the cancel leaves the key alone. 0 skips the check.
956
+
957
+ Returns:
958
+ False when a newer generation holds the key and nothing changed,
959
+ otherwise True.
928
960
  """
929
961
  # Create tombstone with CANCELLED state
930
962
  completed_at = datetime.now(timezone.utc).isoformat()
@@ -938,8 +970,7 @@ class Docket(DocketSnapshotMixin):
938
970
  }
939
971
  )
940
972
 
941
- # Execute the cancellation script
942
- await _cancel_task(
973
+ reply = await _cancel_task(
943
974
  redis,
944
975
  stream_key=self.stream_key,
945
976
  known_key=self.known_task_key(key),
@@ -952,7 +983,12 @@ class Docket(DocketSnapshotMixin):
952
983
  task_key=key,
953
984
  completed_at=completed_at,
954
985
  state_payload=state_payload,
986
+ expected_generation=expected_generation,
955
987
  )
988
+ # The runs hash belongs to the newer generation, so it keeps its own
989
+ # lifetime.
990
+ if reply in (b"SUPERSEDED", "SUPERSEDED"):
991
+ return False
956
992
 
957
993
  # Apply TTL or delete tombstone based on execution_ttl
958
994
  if self.execution_ttl:
@@ -961,6 +997,7 @@ class Docket(DocketSnapshotMixin):
961
997
  else:
962
998
  # execution_ttl=0 means no observability - delete tombstone immediately
963
999
  await redis.delete(task_runs_key)
1000
+ return True
964
1001
 
965
1002
  async def strike(
966
1003
  self,
@@ -1024,8 +1061,17 @@ class Docket(DocketSnapshotMixin):
1024
1061
  """Clear all queued and scheduled tasks from the docket.
1025
1062
 
1026
1063
  This removes all tasks from the stream (immediate tasks) and queue
1027
- (scheduled tasks), along with their associated parked data. Running
1028
- tasks are not affected.
1064
+ (scheduled tasks), along with their associated parked data, and
1065
+ cancels each task that has not started, as ``cancel()`` does: its
1066
+ key is free for a new task at once, and a caller waiting on its
1067
+ result receives ``ExecutionCancelled``. Running tasks are not
1068
+ affected, and neither are tasks that wait for a ``ConcurrencyLimit``
1069
+ slot, which are in neither the stream nor the queue.
1070
+
1071
+ It clears the docket in batches, so other clients can use Redis
1072
+ between them. A worker can start a task between two batches, and
1073
+ that task then runs. A task added during a clear may or may not be
1074
+ cleared.
1029
1075
 
1030
1076
  Returns:
1031
1077
  The total number of tasks that were cleared.
@@ -1034,60 +1080,21 @@ class Docket(DocketSnapshotMixin):
1034
1080
  "docket.clear",
1035
1081
  attributes=self.labels(),
1036
1082
  ):
1083
+ total_cleared = 0
1037
1084
  async with self.redis() as redis:
1038
- async with redis.pipeline() as pipeline:
1039
- # Get counts before clearing
1040
- pipeline.xlen(self.stream_key)
1041
- pipeline.zcard(self.queue_key)
1042
- pipeline.zrange(self.queue_key, 0, -1)
1043
-
1044
- stream_count: int
1045
- queue_count: int
1046
- scheduled_keys: list[bytes]
1047
- stream_count, queue_count, scheduled_keys = await pipeline.execute()
1048
-
1049
- # Get keys from stream messages before trimming
1050
- stream_keys: list[str] = []
1051
- if stream_count > 0:
1052
- # Read all messages from the stream
1053
- messages = await redis.xrange(self.stream_key, "-", "+")
1054
- for message_id, fields in messages:
1055
- # Extract the key field from the message
1056
- if b"key" in fields: # pragma: no branch
1057
- stream_keys.append(fields[b"key"].decode())
1058
-
1059
- async with redis.pipeline() as pipeline:
1060
- # Clear all data
1061
- # Trim stream to 0 messages instead of deleting it to preserve consumer group
1062
- if stream_count > 0:
1063
- pipeline.xtrim(self.stream_key, maxlen=0, approximate=False)
1064
- pipeline.delete(self.queue_key)
1065
-
1066
- # Clear parked task data and known task keys for scheduled tasks
1067
- for key_bytes in scheduled_keys:
1068
- task_key = key_bytes.decode()
1069
- pipeline.delete(self.parked_task_key(task_key))
1070
- pipeline.delete(self.known_task_key(task_key))
1071
- pipeline.delete(self.stream_id_key(task_key))
1072
-
1073
- # Handle runs hash: set TTL or delete based on execution_ttl
1074
- task_runs_key = self.runs_key(task_key)
1075
- if self.execution_ttl:
1076
- ttl_seconds = int(self.execution_ttl.total_seconds())
1077
- pipeline.expire(task_runs_key, ttl_seconds)
1078
- else:
1079
- pipeline.delete(task_runs_key)
1080
-
1081
- # Handle runs hash for immediate tasks from stream
1082
- for task_key in stream_keys:
1083
- task_runs_key = self.runs_key(task_key)
1084
- if self.execution_ttl:
1085
- ttl_seconds = int(self.execution_ttl.total_seconds())
1086
- pipeline.expire(task_runs_key, ttl_seconds)
1087
- else:
1088
- pipeline.delete(task_runs_key)
1089
-
1090
- await pipeline.execute()
1091
-
1092
- total_cleared = stream_count + queue_count
1093
- return total_cleared
1085
+ # A call that clears less than one batch emptied the stream
1086
+ # and the queue, so the clear ends there even while new tasks
1087
+ # keep arriving.
1088
+ cleared = CLEAR_BATCH
1089
+ while cleared >= CLEAR_BATCH:
1090
+ cleared = await _clear(
1091
+ redis,
1092
+ stream_key=self.stream_key,
1093
+ queue_key=self.queue_key,
1094
+ docket_prefix=self.prefix,
1095
+ completed_at=datetime.now(timezone.utc).isoformat(),
1096
+ ttl_seconds=int(self.execution_ttl.total_seconds()),
1097
+ batch=CLEAR_BATCH,
1098
+ )
1099
+ total_cleared += cleared
1100
+ return total_cleared
@@ -205,8 +205,8 @@ class Disposition(enum.Enum):
205
205
  SUPERSEDED = "superseded"
206
206
  """A newer schedule already holds this key, so the attempt left it alone.
207
207
  Only possible when the caller states the generation it expects, which
208
- ``Perpetual`` does when it reschedules the attempt that just finished;
209
- ``Docket.add`` and ``Docket.replace`` never produce it."""
208
+ ``Perpetual`` and ``Retry`` do when they reschedule the attempt that just
209
+ finished; ``Docket.add`` and ``Docket.replace`` never produce it."""
210
210
 
211
211
  FAILED = "failed"
212
212
  """The Redis command scheduling this task returned an error. Only
@@ -279,6 +279,11 @@ class Execution:
279
279
  # False, and the worker can ack defensively.
280
280
  self._acked: bool = False
281
281
 
282
+ # True once ``_mark_as_terminal`` has ended this run. A completion
283
+ # handler that ends the run itself, as a superseded Perpetual does,
284
+ # sets it, and the worker then leaves out its own terminal write.
285
+ self._ended: bool = False
286
+
282
287
  # True when the last claim() refused this task as cancelled. The worker
283
288
  # reads it because a superseded task's state is also CANCELLED when the
284
289
  # newer generation was cancelled.
@@ -681,6 +686,7 @@ class Execution:
681
686
  *,
682
687
  error: str | None = None,
683
688
  result_key: str | None = None,
689
+ publish: bool = True,
684
690
  ) -> None:
685
691
  """Mark task as having reached a terminal state.
686
692
 
@@ -688,6 +694,9 @@ class Execution:
688
694
  state: The terminal state (COMPLETED, FAILED, or CANCELLED)
689
695
  error: Optional error message (for FAILED state)
690
696
  result_key: Optional key where the result/exception is stored
697
+ publish: Whether to publish the terminal-state event. A run that
698
+ a replace superseded passes False, because the key's task has
699
+ not ended.
691
700
 
692
701
  Uses a Lua script to atomically check supersession, write the
693
702
  terminal state, publish the completion event, delete the progress
@@ -722,7 +731,7 @@ class Execution:
722
731
  }
723
732
  if error:
724
733
  state_payload_data["error"] = error
725
- state_payload = json.dumps(state_payload_data)
734
+ state_payload = json.dumps(state_payload_data) if publish else ""
726
735
 
727
736
  # Set ``_acked = True`` *before* the awaited Lua call: the server
728
737
  # commit is the source of truth, so once we hand the call off we own
@@ -731,6 +740,7 @@ class Execution:
731
740
  # and let the worker safety net overwrite the committed terminal
732
741
  # state with FAILED/None.
733
742
  self._acked = True
743
+ self._ended = True
734
744
 
735
745
  with self._maybe_suppress_instrumentation():
736
746
  async with self.docket.redis() as redis:
@@ -9,10 +9,22 @@ local state_channel = KEYS[8]
9
9
  local task_key = ARGV[1]
10
10
  local completed_at = ARGV[2]
11
11
  local state_payload = ARGV[3]
12
+ local expected_generation = tonumber(ARGV[4])
12
13
 
13
14
  -- TODO: Remove known_key / parked_key / stream_id_key handling in
14
15
  -- v0.14.0 (legacy key locations).
15
16
 
17
+ -- A run that cancels its own key (a Perpetual that stops itself) passes
18
+ -- the generation it holds. A newer stored generation means a replace took
19
+ -- the key while that run ran, and the cancel must leave the replacement
20
+ -- alone. 0 means "no check", for a cancel from outside any run.
21
+ if expected_generation > 0 then
22
+ local stored = redis.call('HGET', runs_key, 'generation')
23
+ if stored and tonumber(stored) > expected_generation then
24
+ return 'SUPERSEDED'
25
+ end
26
+ end
27
+
16
28
  -- Get stream ID (check new location first, then legacy)
17
29
  local message_id = redis.call('HGET', runs_key, 'stream_id')
18
30
 
@@ -0,0 +1,95 @@
1
+ local stream_key = KEYS[1]
2
+ local queue_key = KEYS[2]
3
+ local docket_prefix = ARGV[1]
4
+ local completed_at = ARGV[2]
5
+ local ttl_seconds = tonumber(ARGV[3])
6
+ local batch = tonumber(ARGV[4])
7
+
8
+ -- Clearing a docket cancels every task that has not started, the way
9
+ -- cancel_task.lua cancels one, so that each cleared key is free for a new
10
+ -- add() at once and a caller waiting on its result receives the cancelled
11
+ -- state. Each call clears at most `batch` queue entries and `batch` stream
12
+ -- messages, and the caller calls again until one clears less than that. A
13
+ -- script that cleared a large backlog at once would block every other
14
+ -- client for as long as it ran. Each call reads its tasks and removes them, so a task
15
+ -- added in between cannot lose its message and keep its key reserved. A
16
+ -- worker that read a queued task's message before the clear, but has not
17
+ -- claimed it, finds the task cancelled, and claim.lua refuses it.
18
+ --
19
+ -- The stream also holds the messages of tasks that are running. Their
20
+ -- messages go too, but their runs hashes stay as they are, because each
21
+ -- one's worker records how it ends.
22
+
23
+ -- Task keys are user-supplied, and JSON allows no control character in a
24
+ -- string, so this escapes every one of them. cjson is not available on the
25
+ -- in-memory backend.
26
+ local function json_escape(s)
27
+ s = s:gsub('[\\"]', '\\%0')
28
+ s = s:gsub('%c', function(c)
29
+ return string.format('\\u%04x', c:byte())
30
+ end)
31
+ return s
32
+ end
33
+
34
+ local task_keys = {}
35
+ local seen = {}
36
+ local function remember(task_key)
37
+ if not seen[task_key] then
38
+ seen[task_key] = true
39
+ task_keys[#task_keys + 1] = task_key
40
+ end
41
+ end
42
+
43
+ -- Only a scheduled task has parked data, at the docket's prefix and its
44
+ -- key. A key from the stream gets no DEL there: for an immediate task with
45
+ -- a key like "stream", that DEL would remove the stream itself.
46
+ local scheduled = {}
47
+ local queued = redis.call('ZRANGE', queue_key, 0, batch - 1)
48
+ for _, task_key in ipairs(queued) do
49
+ scheduled[task_key] = true
50
+ remember(task_key)
51
+ end
52
+ if #queued > 0 then
53
+ redis.call('ZREM', queue_key, unpack(queued))
54
+ end
55
+
56
+ -- XDEL each message, not DEL the stream, so the stream keeps its consumer
57
+ -- group. The in-memory backend does not run XTRIM inside Lua.
58
+ local messages = redis.call('XRANGE', stream_key, '-', '+', 'COUNT', batch)
59
+ for _, entry in ipairs(messages) do
60
+ local fields = entry[2]
61
+ for i = 1, #fields, 2 do
62
+ if fields[i] == 'key' then
63
+ remember(fields[i + 1])
64
+ break
65
+ end
66
+ end
67
+ redis.call('XDEL', stream_key, entry[1])
68
+ end
69
+
70
+ local prefix = docket_prefix .. ':'
71
+ for _, task_key in ipairs(task_keys) do
72
+ if scheduled[task_key] then
73
+ redis.call('DEL', prefix .. task_key)
74
+ end
75
+ -- TODO: Remove in v0.14.0 (legacy key locations).
76
+ redis.call('DEL', prefix .. 'known:' .. task_key, prefix .. 'stream-id:' .. task_key)
77
+
78
+ local runs_key = prefix .. 'runs:' .. task_key
79
+ local state = redis.call('HGET', runs_key, 'state')
80
+ if state == 'scheduled' or state == 'queued' then
81
+ redis.call('HDEL', runs_key, 'known', 'stream_id')
82
+ redis.call('HSET', runs_key, 'state', 'cancelled', 'completed_at', completed_at)
83
+ redis.call('DEL', prefix .. 'progress:' .. task_key)
84
+ redis.call('PUBLISH', prefix .. 'state:' .. task_key,
85
+ '{"type":"state","key":"' .. json_escape(task_key) ..
86
+ '","state":"cancelled","completed_at":"' .. completed_at .. '"}')
87
+ if ttl_seconds > 0 then
88
+ redis.call('EXPIRE', runs_key, ttl_seconds)
89
+ else
90
+ redis.call('DEL', runs_key)
91
+ end
92
+ end
93
+ end
94
+
95
+ return #messages + #queued
@@ -19,19 +19,18 @@ local message_start = 9
19
19
  -- handling in v0.14.0 (legacy key locations).
20
20
 
21
21
  -- A caller that is rescheduling on behalf of the attempt it just ran
22
- -- (Perpetual's on_complete) passes the generation it holds, so the
23
- -- supersession check rides along with the schedule instead of costing
22
+ -- (Perpetual's on_complete, or a Retry) passes the generation it holds, so
23
+ -- the supersession check rides along with the schedule instead of costing
24
24
  -- its own HGET. A newer stored generation means someone else has taken
25
- -- the key and this schedule must not touch it. 0 means "no check", both
26
- -- for callers that don't have a generation and for messages that predate
27
- -- generation tracking.
25
+ -- the key and this schedule must not touch it. A missing runs hash does
26
+ -- not refuse the schedule: an eviction or an older clear() can remove the
27
+ -- hash of a run that is still going, and that run's retry or next
28
+ -- Perpetual run went ahead before this check existed. 0 means "no check",
29
+ -- both for callers that don't have a generation and for messages that
30
+ -- predate generation tracking.
28
31
  if expected_generation > 0 then
29
32
  local stored = redis.call('HGET', runs_key, 'generation')
30
- local stored_generation = 0
31
- if stored then
32
- stored_generation = tonumber(stored)
33
- end
34
- if stored_generation > expected_generation then
33
+ if stored and tonumber(stored) > expected_generation then
35
34
  return 'SUPERSEDED'
36
35
  end
37
36
  end
@@ -21,10 +21,16 @@ local extra_fields_start = 8
21
21
  -- waiting on completion don't deadlock, and we still clean up this
22
22
  -- execution's progress hash and stream entry. We do NOT recreate or
23
23
  -- mutate the runs hash on a supersession -- the successor owns it.
24
+ --
25
+ -- An empty state_payload publishes nothing. A retry that a replace
26
+ -- superseded passes it, because the key's task has not ended, and a
27
+ -- caller waiting on the key must wait for the replacement.
24
28
  if generation > 0 then
25
29
  local current = redis.call('HGET', runs_key, 'generation')
26
30
  if not current or tonumber(current) > generation then
27
- redis.call('PUBLISH', state_channel, state_payload)
31
+ if state_payload ~= '' then
32
+ redis.call('PUBLISH', state_channel, state_payload)
33
+ end
28
34
  -- Only DEL the progress hash if it belongs to us (matching
29
35
  -- generation tag) or is untagged (pre-fix / pre-tracking data,
30
36
  -- preserve the prior unconditional-DEL behaviour). A newer
@@ -56,7 +62,9 @@ else
56
62
  redis.call('DEL', runs_key)
57
63
  end
58
64
 
59
- redis.call('PUBLISH', state_channel, state_payload)
65
+ if state_payload ~= '' then
66
+ redis.call('PUBLISH', state_channel, state_payload)
67
+ end
60
68
  redis.call('DEL', progress_key)
61
69
  if message_id ~= '' then
62
70
  redis.call('XACK', stream_key, worker_group_name, message_id)