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.
- {pydocket-0.26.2 → pydocket-0.27.0}/PKG-INFO +2 -11
- {pydocket-0.26.2 → pydocket-0.27.0}/pyproject.toml +2 -2
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_execution_scripts.py +2 -1
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/cli/__init__.py +13 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_perpetual.py +40 -17
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_retry.py +38 -5
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/docket.py +68 -61
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/execution.py +13 -3
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/cancel_task.lua +12 -0
- pydocket-0.27.0/src/docket/lua/clear.lua +95 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/schedule.lua +9 -10
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/terminal.lua +10 -2
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/worker.py +38 -20
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_worker.py +42 -0
- pydocket-0.27.0/tests/cli/worker_dependencies.py +31 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_waiter_queue.py +2 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_cron.py +61 -1
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_automatic_perpetual_recovery.py +40 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_dependencies_core.py +2 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_clear.py +133 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_get_result_cancellation.py +32 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_immediate_retry_state.py +2 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_payload_escaping.py +2 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_reliability.py +5 -13
- pydocket-0.27.0/tests/test_perpetual_reschedule.py +209 -0
- pydocket-0.27.0/tests/test_retry_replaced.py +146 -0
- pydocket-0.27.0/tests/worker/test_run_dependencies.py +119 -0
- pydocket-0.26.2/tests/test_perpetual_reschedule.py +0 -97
- {pydocket-0.26.2 → pydocket-0.27.0}/.coveragerc-cli +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/.coveragerc-core +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/.gitignore +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/.pre-commit-config.yaml +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/LICENSE +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/README.md +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/__init__.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/__main__.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_cancellation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_docket_snapshot.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_execution_progress.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_lua.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_prometheus_exporter.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redelivery.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis_credentials.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis_memory.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_redis_sentinel.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_result_store.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_telemetry.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/_uuid7.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/agenda.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/annotations.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/cli/_support.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/__init__.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_base.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_concurrency.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_contextual.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_cooldown.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_cron.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_debounce.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_functional.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_progress.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_ratelimit.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_resolution.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/dependencies/_timeout.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/instrumentation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/acquire_or_park.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/cancel_cleanup.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/claim.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/debounce.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/progress_write.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/ratelimit.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/refresh_lease.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/release_and_wake.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/scavenge_and_wake.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/lua/stream_due_tasks.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/py.typed +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/strikelist.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/tasks.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/src/docket/testing.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/__init__.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/_container.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/_key_leak_checker.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/_rotating_provider.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/__init__.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/run.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_clear.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_iterate_with_timeout.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_module.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_parsing.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_snapshot.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_striking.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_tasks.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_url_validation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_version.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_watch.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/test_workers.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cli/waiting.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cluster/Dockerfile +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cluster/entrypoint.sh +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/cluster/start-cluster.sh +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/__init__.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/overlap.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_annotated.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_basic.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_errors_and_resilience.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_execution_patterns.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_parked_status.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_redelivery.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_waiter_safeguard.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/test_worker_mechanics.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/concurrency_limits/waiters.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/conftest.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/AGENTS.md +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/CLAUDE.md +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/conftest.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_async_dependencies.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_builtin_tasks.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_call_arguments.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_cancellation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_context_injection.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_errors.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_idempotency.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_logging.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_perpetual.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_progress_state.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_results.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_retries.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_scheduling.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_self_perpetuation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_shared_dependencies.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_striking.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_sync_dependencies.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/fundamentals/test_timeouts.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/instrumentation/test_counters.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/instrumentation/test_export.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/instrumentation/test_tracing.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/calc.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/count_argv.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_bool.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_described_key.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_dict.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_keys_and_args.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_list.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_numbers.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/echo_tuple.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/extra_binding.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/join_variadic.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/misdecoded_flag.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/lua/noscript_echo.lua +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/sitecustomize.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_agenda.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_annotated_dependencies.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_batch_scheduling.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_call_arguments.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_cancel_queued_task.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_cancellation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_claim_supersedes.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_cooldown.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_credential_provider.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_credential_rotation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_debounce.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_dependencies_advanced.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_dependency_uniqueness.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_depends_typing.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_execution.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_isolation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_keys.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_registration.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_docket_snapshot.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_execution.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_execution_state.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_exponential_retry_overflow.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_fallback_task.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_handler_semantics.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_interrupted_tasks.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_key_leak_protection.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_lease_renewal.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_lua_decorator.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_memory_backend.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_handoff.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_race.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_perpetual_state.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_progress_basics.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_progress_clobber.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_progress_pubsub.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_pubsub_resp_version.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_ratelimit.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_redelivery.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_redelivery_sweep.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_redis_connection.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_reschedule_clears_worker.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_results_retrieval.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_results_storage.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_reused_keys.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_sentinel_urls.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_socket_timeout.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_strikelist.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_striking.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_task_cycle_round_trips.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_terminal_acked.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_testing.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_uncalled_for_contract.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_uuid7.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_cancellation.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_dependencies.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_single_dependencies.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/test_worker_task_deps_interaction.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_bootstrap.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_core.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_crash.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_delivery_batch.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_invariants.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_lifecycle.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_liveness.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_reconnection.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_scheduling.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_strikes_at_startup.py +0 -0
- {pydocket-0.26.2 → pydocket-0.27.0}/tests/worker/test_task_factory.py +0 -0
- {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.
|
|
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:
|
|
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 =
|
|
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
|
]
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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 ..
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1028
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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``
|
|
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
|
|
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.
|
|
26
|
-
--
|
|
27
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|