labtasker 0.2.10__tar.gz → 0.2.12__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.
- {labtasker-0.2.10 → labtasker-0.2.12}/PKG-INFO +2 -1
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/__init__.py +1 -1
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/worker.py +7 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/client_api.py +2 -2
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/api.py +30 -5
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cli_utils.py +2 -2
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/events.py +2 -2
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/heartbeat.py +16 -5
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/job_runner.py +8 -2
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/query_transpiler.py +23 -4
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/utils.py +27 -1
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/constants.py +0 -1
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/database.py +41 -11
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/endpoints.py +5 -7
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/fsm.py +40 -3
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker.egg-info/SOURCES.txt +2 -1
- {labtasker-0.2.10 → labtasker-0.2.12}/pyproject.toml +2 -2
- labtasker-0.2.12/tests/test_client/test_core/test_heartbeat_e2e.py +73 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_job_runner.py +36 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_fsm.py +1 -1
- {labtasker-0.2.10 → labtasker-0.2.12}/LICENSE +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/MANIFEST.in +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/README.md +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/__main__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/api_models.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/cli.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/config.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/event.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/init.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/loop.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/queue.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/task.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/LabCmd.g4 +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/LabCmdLexer.g4 +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/LabCmd.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/LabCmdLexer.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/LabCmdListener.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/parser.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/config.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/context.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/exceptions.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/logging.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/pager.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/paths.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/plugin_utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/resolver/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/resolver/models.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/resolver/utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/version_checker.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/templates/labtasker_root/.gitignore +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/templates/labtasker_root/client.toml +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/templates/labtasker_root/logs/.gitkeep +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/filtering.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/security.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/cli.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/config.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/db_utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/dependencies.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/embedded_db.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/event_manager.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/logging.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/README.txt +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/BufferedTokenStream.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/CommonTokenFactory.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/CommonTokenStream.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/FileStream.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/InputStream.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/IntervalSet.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/LL1Analyzer.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Lexer.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/ListTokenSource.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Parser.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/ParserInterpreter.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/ParserRuleContext.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/PredictionContext.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Recognizer.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/RuleContext.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/StdinStream.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Token.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/TokenStreamRewriter.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/_pygrun.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATN.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNConfig.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNConfigSet.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNDeserializationOptions.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNDeserializer.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNSimulator.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNState.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNType.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/LexerATNSimulator.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/LexerAction.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/LexerActionExecutor.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ParserATNSimulator.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/PredictionMode.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/SemanticContext.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/Transition.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/DFA.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/DFASerializer.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/DFAState.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/DiagnosticErrorListener.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/ErrorListener.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/ErrorStrategy.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/Errors.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/Chunk.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/ParseTreeMatch.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/ParseTreePattern.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/ParseTreePatternMatcher.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/RuleTagToken.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/TokenTagToken.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/Tree.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/Trees.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/xpath/XPath.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/xpath/XPathLexer.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/xpath/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/vendor.txt +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/setup.cfg +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/conftest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/demo_pager_iterator.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/dummy_jobs/job_1.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/database/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/database/mock.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/database/real.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/logging.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/mock_datetime_now.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/server/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/server/async_app.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/server/sync_app.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_api_models.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/conftest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/conftest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_basic.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_config.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_event.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_init.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_loop.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_queue.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_task.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_worker.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_cli_utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_concurrency_job_flow_event.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_event_listener_basic.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_event_listener_entity_data.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_various_actions.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/utils.py +0 -0
- /labtasker-0.2.10/tests/test_client/test_core/test_heartbeat.py → /labtasker-0.2.12/tests/test_client/test_core/test_heartbeat_unit.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_logging.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_loop_internal_error_handler.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_pager_iterator.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_parser.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/conftest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/test_behavior.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/test_matching.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/test_utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_resolver.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/run_concurrent.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/test_runner_concurrency_success_failure.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/test_runner_high_concurrency.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/conftest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/test_job_runner_timeout.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/test_job_runner_with_resolver_timeout.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_with_resolver.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_server_notification_and_client_version.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_version_checker.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_filtering/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_filtering/exception_utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_filtering/test_exception_filtering.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_mock_time.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_security.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/conftest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/conftest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_database_basic.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_fetch_extra_filter.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_query_dict_to_mongo_filter.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_required_field_fetching.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_arg_match.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_keys_to_query_dict.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_keys_to_query_dict_deepest.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_keys_to_query_dict_topmost.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_embedded_db.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_event_basic.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_server.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_server_async.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_server_async_ping.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_get_verified_queue_dependency.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_utils/__init__.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_utils/test_utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tests/utils.py +0 -0
- {labtasker-0.2.10 → labtasker-0.2.12}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labtasker
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
4
4
|
Summary: A task queue system for lab experiments
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
License: Apache License 2.0
|
|
@@ -57,6 +57,7 @@ Requires-Dist: asgi-lifespan<3.0.0,>=2.1.0; extra == "dev"
|
|
|
57
57
|
Requires-Dist: tox<4.29.0,>=4.24.0; extra == "dev"
|
|
58
58
|
Requires-Dist: pytest-dependency<0.7.0,>=0.6.0; extra == "dev"
|
|
59
59
|
Requires-Dist: pytest-sugar<2.0.0,>=1.0.0; extra == "dev"
|
|
60
|
+
Requires-Dist: rust-just<2.0.0,>=1.42.4; extra == "dev"
|
|
60
61
|
Provides-Extra: doc
|
|
61
62
|
Requires-Dist: mkdocs-material<9.7.0,>=9.6.5; extra == "doc"
|
|
62
63
|
Requires-Dist: mkdocs-glightbox<0.5.0,>=0.4.0; extra == "doc"
|
|
@@ -102,6 +102,12 @@ def ls(
|
|
|
102
102
|
"--name",
|
|
103
103
|
help="Filter by worker name.",
|
|
104
104
|
),
|
|
105
|
+
status: Optional[str] = typer.Option(
|
|
106
|
+
None,
|
|
107
|
+
"--status",
|
|
108
|
+
"-s",
|
|
109
|
+
help="Filter by worker status. One of `active`, `suspended`, `crashed`.",
|
|
110
|
+
),
|
|
105
111
|
extra_filter: Optional[str] = typer.Option(
|
|
106
112
|
None,
|
|
107
113
|
"--extra-filter",
|
|
@@ -164,6 +170,7 @@ def ls(
|
|
|
164
170
|
ls_workers,
|
|
165
171
|
worker_id=worker_id,
|
|
166
172
|
worker_name=worker_name,
|
|
173
|
+
status=status,
|
|
167
174
|
extra_filter=extra_filter,
|
|
168
175
|
),
|
|
169
176
|
offset=offset,
|
|
@@ -70,7 +70,7 @@ assert len(set(__all__)) == len(__all__), "Duplicated symbols in __all__"
|
|
|
70
70
|
|
|
71
71
|
def loop(
|
|
72
72
|
required_fields: Optional[List[str]] = None,
|
|
73
|
-
extra_filter: Optional[Dict[str, Any]] = None,
|
|
73
|
+
extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
|
|
74
74
|
cmd: Optional[Union[str, List[str]]] = None,
|
|
75
75
|
worker_id: Optional[str] = None,
|
|
76
76
|
create_worker_kwargs: Optional[Dict[str, Any]] = None,
|
|
@@ -82,7 +82,7 @@ def loop(
|
|
|
82
82
|
|
|
83
83
|
Args:
|
|
84
84
|
required_fields: Fields (or extra fields other than specified using Required(...)) required for task execution in a dot-separated manner. E.g. ["arg1.arg11", "arg2.arg22"]
|
|
85
|
-
extra_filter: Additional filtering criteria for tasks
|
|
85
|
+
extra_filter: Additional filtering criteria for tasks. Dict in MongoDB syntax or string in Python syntax is allowed.
|
|
86
86
|
cmd: Command line arguments that runs current process. Default to sys.argv
|
|
87
87
|
worker_id: Specific worker ID to use
|
|
88
88
|
create_worker_kwargs: Arguments for default worker creation
|
|
@@ -35,6 +35,7 @@ from labtasker.client.core.utils import (
|
|
|
35
35
|
cast_http_error,
|
|
36
36
|
display_server_notifications,
|
|
37
37
|
raise_for_status,
|
|
38
|
+
transpile_query_safe,
|
|
38
39
|
)
|
|
39
40
|
from labtasker.constants import Priority
|
|
40
41
|
from labtasker.security import SecretStr, get_auth_headers
|
|
@@ -63,11 +64,21 @@ __all__ = [
|
|
|
63
64
|
]
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
def _is_network_transient_error(exception):
|
|
68
|
+
return isinstance(exception, (httpx.TransportError, ConnectionError, TimeoutError))
|
|
69
|
+
|
|
70
|
+
|
|
66
71
|
def _network_err_retry(func):
|
|
67
72
|
@wraps(func)
|
|
68
73
|
def wrapper(*args, **kwargs):
|
|
69
74
|
return stamina.retry(
|
|
70
|
-
on=
|
|
75
|
+
on=_is_network_transient_error,
|
|
76
|
+
attempts=10,
|
|
77
|
+
timeout=100.0,
|
|
78
|
+
wait_initial=0.5,
|
|
79
|
+
wait_max=16.0,
|
|
80
|
+
wait_jitter=1.0,
|
|
81
|
+
wait_exp_base=2.0,
|
|
71
82
|
)(func)(*args, **kwargs)
|
|
72
83
|
|
|
73
84
|
return wrapper
|
|
@@ -193,7 +204,7 @@ def fetch_task(
|
|
|
193
204
|
heartbeat_timeout: Optional[float] = None,
|
|
194
205
|
start_heartbeat: bool = True,
|
|
195
206
|
required_fields: Optional[List[str]] = None,
|
|
196
|
-
extra_filter: Optional[Dict[str, Any]] = None,
|
|
207
|
+
extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
|
|
197
208
|
client: Optional[httpx.Client] = None,
|
|
198
209
|
cmd: Optional[Union[str, List[str]]] = None,
|
|
199
210
|
) -> TaskFetchResponse:
|
|
@@ -206,6 +217,9 @@ def fetch_task(
|
|
|
206
217
|
"Either eta_max or start_heartbeat must be specified."
|
|
207
218
|
)
|
|
208
219
|
|
|
220
|
+
if isinstance(extra_filter, str): # transpile to mongodb query
|
|
221
|
+
extra_filter = transpile_query_safe(query_str=extra_filter)
|
|
222
|
+
|
|
209
223
|
payload = TaskFetchRequest(
|
|
210
224
|
worker_id=worker_id,
|
|
211
225
|
eta_max=eta_max,
|
|
@@ -268,12 +282,15 @@ def report_task_status(
|
|
|
268
282
|
@_network_err_retry
|
|
269
283
|
def refresh_task_heartbeat(
|
|
270
284
|
task_id: str,
|
|
285
|
+
worker_id: Optional[str] = None,
|
|
271
286
|
client: Optional[httpx.Client] = None,
|
|
272
287
|
) -> None:
|
|
273
288
|
"""Refresh the heartbeat of a task."""
|
|
274
289
|
if client is None:
|
|
275
290
|
client = get_httpx_client()
|
|
276
|
-
response = client.post(
|
|
291
|
+
response = client.post(
|
|
292
|
+
f"/api/v1/queues/me/tasks/{task_id}/heartbeat", params={"worker_id": worker_id}
|
|
293
|
+
)
|
|
277
294
|
raise_for_status(response)
|
|
278
295
|
|
|
279
296
|
|
|
@@ -303,7 +320,7 @@ def ls_workers(
|
|
|
303
320
|
worker_id: Optional[str] = None,
|
|
304
321
|
worker_name: Optional[str] = None,
|
|
305
322
|
status: Optional[str] = None,
|
|
306
|
-
extra_filter: Optional[Dict[str, Any]] = None,
|
|
323
|
+
extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
|
|
307
324
|
limit: int = 100,
|
|
308
325
|
offset: int = 0,
|
|
309
326
|
sort: Optional[List[Tuple[str, int]]] = None,
|
|
@@ -312,6 +329,10 @@ def ls_workers(
|
|
|
312
329
|
"""List workers."""
|
|
313
330
|
if client is None:
|
|
314
331
|
client = get_httpx_client()
|
|
332
|
+
|
|
333
|
+
if isinstance(extra_filter, str): # transpile to mongodb query
|
|
334
|
+
extra_filter = transpile_query_safe(query_str=extra_filter)
|
|
335
|
+
|
|
315
336
|
payload = WorkerLsRequest(
|
|
316
337
|
worker_id=worker_id,
|
|
317
338
|
worker_name=worker_name,
|
|
@@ -364,7 +385,7 @@ def ls_tasks(
|
|
|
364
385
|
task_id: Optional[str] = None,
|
|
365
386
|
task_name: Optional[str] = None,
|
|
366
387
|
status: Optional[str] = None,
|
|
367
|
-
extra_filter: Optional[Dict[str, Any]] = None,
|
|
388
|
+
extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
|
|
368
389
|
limit: int = 100,
|
|
369
390
|
offset: int = 0,
|
|
370
391
|
sort: Optional[List[Tuple[str, int]]] = None,
|
|
@@ -373,6 +394,10 @@ def ls_tasks(
|
|
|
373
394
|
"""List tasks in a queue."""
|
|
374
395
|
if client is None:
|
|
375
396
|
client = get_httpx_client()
|
|
397
|
+
|
|
398
|
+
if isinstance(extra_filter, str): # transpile to mongodb query
|
|
399
|
+
extra_filter = transpile_query_safe(query_str=extra_filter)
|
|
400
|
+
|
|
376
401
|
payload = TaskLsRequest(
|
|
377
402
|
task_id=task_id,
|
|
378
403
|
task_name=task_name,
|
|
@@ -34,7 +34,7 @@ from labtasker.client.core.exceptions import (
|
|
|
34
34
|
QueryTranspilerError,
|
|
35
35
|
)
|
|
36
36
|
from labtasker.client.core.logging import stderr_console
|
|
37
|
-
from labtasker.client.core.
|
|
37
|
+
from labtasker.client.core.utils import transpile_query_safe
|
|
38
38
|
from labtasker.utils import parse_time_interval, unflatten_dict
|
|
39
39
|
|
|
40
40
|
DT = TypeVar("DT")
|
|
@@ -84,7 +84,7 @@ def parse_filter(filter_str: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
|
84
84
|
return parse_dict(d_str=filter_str)
|
|
85
85
|
except typer.BadParameter:
|
|
86
86
|
try:
|
|
87
|
-
return
|
|
87
|
+
return transpile_query_safe(query_str=filter_str) # type: ignore[arg-type]
|
|
88
88
|
except QueryTranspilerError as e:
|
|
89
89
|
raise typer.BadParameter(f"Invalid filter str: {e}") from e
|
|
90
90
|
|
|
@@ -176,9 +176,9 @@ class EventListener:
|
|
|
176
176
|
self._retry_context_iter = stamina.retry_context(
|
|
177
177
|
on=httpx.TransportError,
|
|
178
178
|
attempts=10,
|
|
179
|
-
timeout=
|
|
179
|
+
timeout=100.0,
|
|
180
180
|
wait_initial=0.5,
|
|
181
|
-
wait_max=
|
|
181
|
+
wait_max=16.0,
|
|
182
182
|
wait_jitter=1.0,
|
|
183
183
|
wait_exp_base=2.0,
|
|
184
184
|
).__iter__()
|
|
@@ -18,8 +18,9 @@ __all__ = [
|
|
|
18
18
|
|
|
19
19
|
class Heartbeat:
|
|
20
20
|
|
|
21
|
-
def __init__(self, task_id, heartbeat_interval):
|
|
21
|
+
def __init__(self, task_id, worker_id, heartbeat_interval):
|
|
22
22
|
self.task_id = task_id
|
|
23
|
+
self.worker_id = worker_id
|
|
23
24
|
self.heartbeat_interval = heartbeat_interval
|
|
24
25
|
|
|
25
26
|
self._thread = None
|
|
@@ -39,6 +40,7 @@ class Heartbeat:
|
|
|
39
40
|
self._thread.start()
|
|
40
41
|
|
|
41
42
|
def delay(self, interval: float) -> bool:
|
|
43
|
+
"""Returns False if it should exit."""
|
|
42
44
|
slice_t = 0.05 # check for stop event
|
|
43
45
|
start_time = time.perf_counter()
|
|
44
46
|
while True:
|
|
@@ -67,11 +69,12 @@ class Heartbeat:
|
|
|
67
69
|
"""Refresh heartbeat periodically"""
|
|
68
70
|
while True:
|
|
69
71
|
try:
|
|
70
|
-
refresh_task_heartbeat(task_id=self.task_id)
|
|
72
|
+
refresh_task_heartbeat(task_id=self.task_id, worker_id=self.worker_id)
|
|
71
73
|
except Exception as e:
|
|
72
|
-
logger.error(f"
|
|
74
|
+
logger.error(f"Failed to refresh heartbeat: {str(e)}")
|
|
75
|
+
raise
|
|
73
76
|
|
|
74
|
-
#
|
|
77
|
+
# Check if heartbeat should stop
|
|
75
78
|
if not self.delay(self.heartbeat_interval):
|
|
76
79
|
break
|
|
77
80
|
|
|
@@ -85,6 +88,9 @@ class Heartbeat:
|
|
|
85
88
|
except FileNotFoundError:
|
|
86
89
|
pass
|
|
87
90
|
|
|
91
|
+
def is_alive(self):
|
|
92
|
+
return self._thread and self._thread.is_alive()
|
|
93
|
+
|
|
88
94
|
|
|
89
95
|
_current_heartbeat: ContextVar[Optional[Heartbeat]] = ContextVar(
|
|
90
96
|
"heartbeat", default=None
|
|
@@ -92,7 +98,10 @@ _current_heartbeat: ContextVar[Optional[Heartbeat]] = ContextVar(
|
|
|
92
98
|
|
|
93
99
|
|
|
94
100
|
def start_heartbeat(
|
|
95
|
-
task_id,
|
|
101
|
+
task_id,
|
|
102
|
+
worker_id: Optional[str] = None,
|
|
103
|
+
heartbeat_interval: Optional[float] = None,
|
|
104
|
+
raise_error=True,
|
|
96
105
|
):
|
|
97
106
|
logger.debug("Try starting heartbeat.")
|
|
98
107
|
if _current_heartbeat.get() is not None:
|
|
@@ -102,12 +111,14 @@ def start_heartbeat(
|
|
|
102
111
|
|
|
103
112
|
heartbeat_manager = Heartbeat(
|
|
104
113
|
task_id=task_id,
|
|
114
|
+
worker_id=worker_id,
|
|
105
115
|
heartbeat_interval=heartbeat_interval
|
|
106
116
|
or get_client_config().task.heartbeat_interval,
|
|
107
117
|
)
|
|
108
118
|
heartbeat_manager.start()
|
|
109
119
|
_current_heartbeat.set(heartbeat_manager)
|
|
110
120
|
logger.debug("Heartbeat started.")
|
|
121
|
+
return heartbeat_manager
|
|
111
122
|
|
|
112
123
|
|
|
113
124
|
def end_heartbeat(raise_error=True):
|
|
@@ -38,6 +38,7 @@ from labtasker.client.core.exceptions import (
|
|
|
38
38
|
from labtasker.client.core.heartbeat import end_heartbeat, start_heartbeat
|
|
39
39
|
from labtasker.client.core.logging import log_to_file, logger, stderr_console
|
|
40
40
|
from labtasker.client.core.paths import get_labtasker_log_dir, set_labtasker_log_dir
|
|
41
|
+
from labtasker.client.core.utils import transpile_query_safe
|
|
41
42
|
from labtasker.utils import parse_time_interval
|
|
42
43
|
|
|
43
44
|
__all__ = [
|
|
@@ -106,7 +107,7 @@ def dump_task_info():
|
|
|
106
107
|
|
|
107
108
|
def loop_run(
|
|
108
109
|
required_fields: List[str],
|
|
109
|
-
extra_filter: Optional[Dict[str, Any]] = None,
|
|
110
|
+
extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
|
|
110
111
|
cmd: Optional[Union[str, List[str]]] = None,
|
|
111
112
|
worker_id: Optional[str] = None,
|
|
112
113
|
create_worker_kwargs: Optional[Dict[str, Any]] = None,
|
|
@@ -142,6 +143,9 @@ def loop_run(
|
|
|
142
143
|
f"Invalid eta_max {eta_max}. ETA max must be a valid duration string (e.g. '1h', '1h30m', '50s')"
|
|
143
144
|
)
|
|
144
145
|
|
|
146
|
+
if isinstance(extra_filter, str): # transpile to mongodb query
|
|
147
|
+
extra_filter = transpile_query_safe(query_str=extra_filter)
|
|
148
|
+
|
|
145
149
|
# Check connection and authentication
|
|
146
150
|
try:
|
|
147
151
|
get_queue()
|
|
@@ -216,7 +220,9 @@ def loop_run(
|
|
|
216
220
|
dump_task_info()
|
|
217
221
|
|
|
218
222
|
with log_to_file(file_path=get_labtasker_log_dir() / "run.log"):
|
|
219
|
-
start_heartbeat(
|
|
223
|
+
start_heartbeat(
|
|
224
|
+
task_id=current_task_id(), worker_id=current_worker_id()
|
|
225
|
+
)
|
|
220
226
|
success_flag = False
|
|
221
227
|
try:
|
|
222
228
|
func_args = (task.args, *args) if pass_args_dict else args
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ast
|
|
2
|
+
import difflib
|
|
2
3
|
from datetime import timezone
|
|
3
|
-
from typing import Any, Dict, List, NoReturn, Type
|
|
4
|
+
from typing import Any, Dict, List, NoReturn, Optional, Type
|
|
4
5
|
|
|
5
6
|
import dateparser
|
|
6
7
|
from rich.console import Console
|
|
@@ -154,9 +155,10 @@ class QueryTranspiler(ast.NodeVisitor):
|
|
|
154
155
|
ast.Or: "$or", # a or b -> {$or: [a, b]}
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
def __init__(self, query_str: str):
|
|
158
|
+
def __init__(self, query_str: str, allowed_fields: Optional[List[str]] = None):
|
|
158
159
|
super().__init__()
|
|
159
160
|
self.query_str = query_str
|
|
161
|
+
self.allowed_fields = allowed_fields
|
|
160
162
|
|
|
161
163
|
def _report_error(
|
|
162
164
|
self, node: ast.AST, msg: str, exception: Type[QueryTranspilerError]
|
|
@@ -701,6 +703,20 @@ class QueryTranspiler(ast.NodeVisitor):
|
|
|
701
703
|
Python: field_name
|
|
702
704
|
MongoDB: "field_name" (as a field reference)
|
|
703
705
|
"""
|
|
706
|
+
if self.allowed_fields and node.id not in self.allowed_fields:
|
|
707
|
+
suggestions = difflib.get_close_matches(
|
|
708
|
+
node.id, self.allowed_fields, n=1, cutoff=0.6
|
|
709
|
+
)
|
|
710
|
+
suggestion_msg = (
|
|
711
|
+
f" Maybe you meant '{suggestions[0]}'?" if suggestions else ""
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
self._report_error(
|
|
715
|
+
node=node,
|
|
716
|
+
msg=f"Field '{node.id}' is unknown or not allowed.{suggestion_msg}"
|
|
717
|
+
f"\nAllowed fields: {', '.join(sorted(self.allowed_fields))}",
|
|
718
|
+
exception=QueryTranspilerValueError,
|
|
719
|
+
)
|
|
704
720
|
return node.id
|
|
705
721
|
|
|
706
722
|
def visit_Attribute(self, node: ast.Attribute) -> str:
|
|
@@ -960,7 +976,9 @@ class QueryTranspiler(ast.NodeVisitor):
|
|
|
960
976
|
)
|
|
961
977
|
|
|
962
978
|
|
|
963
|
-
def transpile_query(
|
|
979
|
+
def transpile_query(
|
|
980
|
+
query_str: str, allowed_fields: Optional[List[str]] = None
|
|
981
|
+
) -> Dict[str, Any]:
|
|
964
982
|
"""
|
|
965
983
|
Transpile a Python-like query string and convert it to a MongoDB query object.
|
|
966
984
|
|
|
@@ -969,6 +987,7 @@ def transpile_query(query_str: str) -> Dict[str, Any]:
|
|
|
969
987
|
|
|
970
988
|
Args:
|
|
971
989
|
query_str: A string containing a Python-like expression to be converted
|
|
990
|
+
allowed_fields: A list of allowed fields (e.g. ast.Name.id). If None, allowed_fields will not be checked.
|
|
972
991
|
|
|
973
992
|
Returns:
|
|
974
993
|
A dictionary representing the equivalent MongoDB query
|
|
@@ -979,7 +998,7 @@ def transpile_query(query_str: str) -> Dict[str, Any]:
|
|
|
979
998
|
try:
|
|
980
999
|
query_str = query_str.strip()
|
|
981
1000
|
parsed_ast = ast.parse(query_str)
|
|
982
|
-
visitor = QueryTranspiler(query_str=query_str)
|
|
1001
|
+
visitor = QueryTranspiler(query_str=query_str, allowed_fields=allowed_fields)
|
|
983
1002
|
result = visitor.visit(parsed_ast)
|
|
984
1003
|
|
|
985
1004
|
if isinstance(result, bool) or isinstance(result, int):
|
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import subprocess
|
|
4
4
|
import sys
|
|
5
5
|
import threading
|
|
6
|
-
from functools import wraps
|
|
6
|
+
from functools import partial, wraps
|
|
7
7
|
from typing import Any, Callable, Optional
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
@@ -20,6 +20,7 @@ from labtasker.client.core.exceptions import (
|
|
|
20
20
|
)
|
|
21
21
|
from labtasker.client.core.logging import stderr_console, stdout_console
|
|
22
22
|
from labtasker.client.core.paths import get_labtasker_client_config_path
|
|
23
|
+
from labtasker.client.core.query_transpiler import transpile_query
|
|
23
24
|
|
|
24
25
|
server_notification_prefix = {
|
|
25
26
|
"info": "[bold dodger_blue1]INFO(notification):[/bold dodger_blue1] ",
|
|
@@ -33,6 +34,31 @@ server_notification_level = {
|
|
|
33
34
|
"high": 2,
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
transpile_query_safe = partial(
|
|
38
|
+
transpile_query,
|
|
39
|
+
allowed_fields=[
|
|
40
|
+
"task_id",
|
|
41
|
+
"queue_id",
|
|
42
|
+
"status",
|
|
43
|
+
"task_name",
|
|
44
|
+
"created_at",
|
|
45
|
+
"start_time",
|
|
46
|
+
"last_heartbeat",
|
|
47
|
+
"last_modified",
|
|
48
|
+
"heartbeat_timeout",
|
|
49
|
+
"task_timeout",
|
|
50
|
+
"max_retries",
|
|
51
|
+
"retries",
|
|
52
|
+
"priority",
|
|
53
|
+
"metadata",
|
|
54
|
+
"args",
|
|
55
|
+
"cmd",
|
|
56
|
+
"summary",
|
|
57
|
+
"worker_id",
|
|
58
|
+
"worker_name",
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
|
|
36
62
|
|
|
37
63
|
def json_serializer(obj: Any, **kwargs) -> str:
|
|
38
64
|
return json.dumps(to_jsonable_python(obj), **kwargs)
|
|
@@ -674,22 +674,52 @@ class DBService:
|
|
|
674
674
|
@retry_on_transient
|
|
675
675
|
@validate_arg
|
|
676
676
|
def refresh_task_heartbeat(
|
|
677
|
-
self,
|
|
678
|
-
|
|
679
|
-
task_id: str,
|
|
680
|
-
) -> bool:
|
|
677
|
+
self, queue_id: str, task_id: str, worker_id: Optional[str] = None
|
|
678
|
+
):
|
|
681
679
|
"""Update task heartbeat timestamp."""
|
|
680
|
+
query = {"_id": task_id, "queue_id": queue_id, "status": "running"}
|
|
681
|
+
|
|
682
682
|
with self._client.start_session() as session:
|
|
683
683
|
with session.start_transaction():
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
684
|
+
# Find the task in a single query
|
|
685
|
+
task = self._tasks.find_one(query)
|
|
686
|
+
if not task:
|
|
687
|
+
raise HTTPException(
|
|
688
|
+
status_code=HTTP_404_NOT_FOUND,
|
|
689
|
+
detail=f"Task '{task_id}' not found in queue '{queue_id}' or not in 'running' state",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Validate worker if provided
|
|
693
|
+
if worker_id:
|
|
694
|
+
if task["worker_id"] != worker_id:
|
|
695
|
+
raise HTTPException(
|
|
696
|
+
status_code=HTTP_403_FORBIDDEN,
|
|
697
|
+
detail=f"Task '{task_id}' is assigned to worker '{task['worker_id']}', not '{worker_id}'",
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# Check worker status in a single query
|
|
701
|
+
worker = self._workers.find_one(
|
|
702
|
+
{"_id": worker_id, "status": WorkerState.ACTIVE}
|
|
703
|
+
)
|
|
704
|
+
if not worker:
|
|
705
|
+
raise HTTPException(
|
|
706
|
+
status_code=HTTP_404_NOT_FOUND,
|
|
707
|
+
detail=f"Worker '{worker_id}' not found or not active",
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# Update the task heartbeat
|
|
711
|
+
result = self._tasks.update_one(
|
|
712
|
+
query,
|
|
713
|
+
{"$set": {"last_heartbeat": get_current_time()}},
|
|
714
|
+
session=session,
|
|
691
715
|
)
|
|
692
716
|
|
|
717
|
+
if result.modified_count == 0:
|
|
718
|
+
raise HTTPException(
|
|
719
|
+
status_code=HTTP_404_NOT_FOUND,
|
|
720
|
+
detail=f"Failed to update heartbeat for task '{task_id}' - it may have changed state during the operation",
|
|
721
|
+
)
|
|
722
|
+
|
|
693
723
|
@retry_on_transient
|
|
694
724
|
@validate_arg
|
|
695
725
|
def worker_report_task_status(
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import uuid
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
|
-
from typing import Any, Dict, List
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
5
|
|
|
6
|
-
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
6
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|
7
7
|
from sse_starlette.sse import EventSourceResponse
|
|
8
8
|
from starlette.status import (
|
|
9
9
|
HTTP_201_CREATED,
|
|
@@ -309,16 +309,14 @@ def report_task_status(
|
|
|
309
309
|
)
|
|
310
310
|
def refresh_task_heartbeat(
|
|
311
311
|
task_id: str,
|
|
312
|
+
worker_id: Optional[str] = Query(None), # use query param
|
|
312
313
|
queue: Dict[str, Any] = Depends(get_verified_queue_dependency),
|
|
313
314
|
db: DBService = Depends(get_db),
|
|
314
315
|
):
|
|
315
316
|
"""Update task heartbeat timestamp."""
|
|
316
|
-
|
|
317
|
-
queue_id=queue["_id"],
|
|
318
|
-
task_id=task_id,
|
|
317
|
+
db.refresh_task_heartbeat(
|
|
318
|
+
queue_id=queue["_id"], task_id=task_id, worker_id=worker_id
|
|
319
319
|
)
|
|
320
|
-
if not done:
|
|
321
|
-
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Task not found.")
|
|
322
320
|
|
|
323
321
|
|
|
324
322
|
@app.get(
|
|
@@ -43,7 +43,12 @@ class StateTransitionEventHandle:
|
|
|
43
43
|
self.commit()
|
|
44
44
|
|
|
45
45
|
def commit(self):
|
|
46
|
-
event_data =
|
|
46
|
+
event_data = self._create_event_data()
|
|
47
|
+
self._publish_event(event_data)
|
|
48
|
+
self._entity_data = None
|
|
49
|
+
|
|
50
|
+
def _create_event_data(self):
|
|
51
|
+
return StateTransitionEvent(
|
|
47
52
|
entity_type=self.entity_type,
|
|
48
53
|
queue_id=self.queue_id,
|
|
49
54
|
entity_id=self.entity_id,
|
|
@@ -54,9 +59,17 @@ class StateTransitionEventHandle:
|
|
|
54
59
|
entity_data=self._entity_data,
|
|
55
60
|
)
|
|
56
61
|
|
|
62
|
+
def _publish_event(self, event_data):
|
|
57
63
|
# Use fully synchronous event publishing
|
|
58
64
|
event_manager.publish_event(self.queue_id, event_data)
|
|
59
|
-
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NullEventHandle(StateTransitionEventHandle):
|
|
68
|
+
"""A placeholder that does nothing. (Used for cases where triggering event publishing is undesired)"""
|
|
69
|
+
|
|
70
|
+
def _publish_event(self, event_data):
|
|
71
|
+
# Override to do nothing
|
|
72
|
+
pass
|
|
60
73
|
|
|
61
74
|
|
|
62
75
|
class State(str, Enum):
|
|
@@ -140,6 +153,18 @@ class BaseFSM:
|
|
|
140
153
|
def state(self):
|
|
141
154
|
return self._state
|
|
142
155
|
|
|
156
|
+
def null_transition(self) -> NullEventHandle:
|
|
157
|
+
"""Perform a null transition and return a handle"""
|
|
158
|
+
return NullEventHandle(
|
|
159
|
+
entity_type=self.ENTITY_TYPE,
|
|
160
|
+
entity_id=self.entity_id,
|
|
161
|
+
queue_id=self.queue_id,
|
|
162
|
+
old_state=str(self._state),
|
|
163
|
+
new_state=str(self._state),
|
|
164
|
+
transition_time=get_current_time(),
|
|
165
|
+
metadata=self.metadata,
|
|
166
|
+
)
|
|
167
|
+
|
|
143
168
|
def transition_to(self, new_state: State) -> StateTransitionEventHandle:
|
|
144
169
|
"""Perform state transition and return a handle"""
|
|
145
170
|
old_state = self._state
|
|
@@ -190,6 +215,7 @@ class TaskFSM(BaseFSM):
|
|
|
190
215
|
TaskState.FAILED: {
|
|
191
216
|
TaskState.PENDING,
|
|
192
217
|
TaskState.CANCELLED,
|
|
218
|
+
TaskState.FAILED, # null transition (for more tolerance)
|
|
193
219
|
}, # Can be reset and requeued
|
|
194
220
|
TaskState.CANCELLED: {
|
|
195
221
|
TaskState.PENDING,
|
|
@@ -280,11 +306,15 @@ class TaskFSM(BaseFSM):
|
|
|
280
306
|
Transitions:
|
|
281
307
|
- RUNNING -> PENDING (if retries < max_retries)
|
|
282
308
|
- RUNNING -> FAILED (if retries >= max_retries)
|
|
309
|
+
- FAILED -> FAILED (null transition, does nothing)
|
|
283
310
|
- Others -> InvalidStateTransition (invalid)
|
|
284
311
|
|
|
285
312
|
Note: FAILED state can transition back to PENDING for retries
|
|
286
313
|
until max_retries is reached.
|
|
287
314
|
"""
|
|
315
|
+
if self.state == TaskState.FAILED:
|
|
316
|
+
return self.null_transition()
|
|
317
|
+
|
|
288
318
|
if self.state != TaskState.RUNNING:
|
|
289
319
|
raise InvalidStateTransition(f"Cannot fail task in {self.state} state")
|
|
290
320
|
|
|
@@ -305,7 +335,10 @@ class WorkerFSM(BaseFSM):
|
|
|
305
335
|
WorkerState.CRASHED,
|
|
306
336
|
},
|
|
307
337
|
WorkerState.SUSPENDED: {WorkerState.ACTIVE}, # Manual transition
|
|
308
|
-
WorkerState.CRASHED: {
|
|
338
|
+
WorkerState.CRASHED: {
|
|
339
|
+
WorkerState.ACTIVE, # Manual transition
|
|
340
|
+
WorkerState.CRASHED, # null transition (for more tolerance)
|
|
341
|
+
},
|
|
309
342
|
}
|
|
310
343
|
|
|
311
344
|
def __init__(
|
|
@@ -366,7 +399,11 @@ class WorkerFSM(BaseFSM):
|
|
|
366
399
|
Transitions:
|
|
367
400
|
- ACTIVE -> ACTIVE
|
|
368
401
|
- ACTIVE -> CRASHED (retries >= max_retries)
|
|
402
|
+
- CRASHED -> CRASHED (null transition, does nothing)
|
|
369
403
|
"""
|
|
404
|
+
if self.state == WorkerState.CRASHED:
|
|
405
|
+
return self.null_transition()
|
|
406
|
+
|
|
370
407
|
if self.state != WorkerState.ACTIVE:
|
|
371
408
|
raise InvalidStateTransition(f"Cannot fail worker in {self.state} state")
|
|
372
409
|
|
|
@@ -154,7 +154,8 @@ tests/test_client/test_cli/test_task.py
|
|
|
154
154
|
tests/test_client/test_cli/test_worker.py
|
|
155
155
|
tests/test_client/test_core/__init__.py
|
|
156
156
|
tests/test_client/test_core/test_cli_utils.py
|
|
157
|
-
tests/test_client/test_core/
|
|
157
|
+
tests/test_client/test_core/test_heartbeat_e2e.py
|
|
158
|
+
tests/test_client/test_core/test_heartbeat_unit.py
|
|
158
159
|
tests/test_client/test_core/test_job_runner.py
|
|
159
160
|
tests/test_client/test_core/test_logging.py
|
|
160
161
|
tests/test_client/test_core/test_loop_internal_error_handler.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "labtasker"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.12"
|
|
8
8
|
description = "A task queue system for lab experiments"
|
|
9
9
|
authors = [{ name = "Your Name", email = "your.email@example.com" }]
|
|
10
10
|
license = { text = "Apache License 2.0" }
|
|
@@ -64,6 +64,7 @@ dev = [
|
|
|
64
64
|
"tox (>=4.24.0,<4.29.0)",
|
|
65
65
|
"pytest-dependency (>=0.6.0,<0.7.0)",
|
|
66
66
|
"pytest-sugar (>=1.0.0,<2.0.0)",
|
|
67
|
+
"rust-just (>=1.42.4,<2.0.0)",
|
|
67
68
|
]
|
|
68
69
|
doc = [
|
|
69
70
|
"mkdocs-material (>=9.6.5,<9.7.0)",
|
|
@@ -132,7 +133,6 @@ disable_error_code = [
|
|
|
132
133
|
"no-redef",
|
|
133
134
|
"import-untyped"
|
|
134
135
|
]
|
|
135
|
-
python_version = "3.10"
|
|
136
136
|
warn_unused_configs = true
|
|
137
137
|
ignore_missing_imports = true
|
|
138
138
|
show_error_codes = true
|