feat: expand module workflows, dynamic config, notifications and console API
This commit is contained in:
@@ -16,7 +16,7 @@ import pytest
|
||||
import gyxx_flow.workflow.steps as steps_module
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.layout import DataLayout
|
||||
from gyxx_flow.core.locks import LockManager
|
||||
from gyxx_flow.core.locks import LockManager, ResourceBusyError
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
from gyxx_flow.ops.effects import EffectLedger
|
||||
from gyxx_flow.workflow.engine import WorkflowEngine
|
||||
@@ -159,6 +159,22 @@ def test_command_step_uses_argv_explicit_cwd_env_and_timeout(
|
||||
assert captured["stderr"].closed is True # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_command_step_preserves_chinese_child_diagnostics(tmp_path: Path) -> None:
|
||||
child_env = dict(os.environ)
|
||||
child_env.pop("PYTHONIOENCODING", None)
|
||||
expected = "天猫百亿补贴导入失败"
|
||||
step = CommandStep(
|
||||
argv=(sys.executable, "-c", f"print({expected!r})"),
|
||||
cwd=tmp_path,
|
||||
env=child_env,
|
||||
)
|
||||
|
||||
outcome = step.execute(context=_context(), timeout_seconds=10, dry_run=False)
|
||||
|
||||
assert outcome.exit_code == 0
|
||||
assert outcome.stdout.strip() == expected
|
||||
|
||||
|
||||
def test_command_step_dry_run_never_starts_a_process(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
@@ -359,6 +375,31 @@ def test_command_step_maps_reserved_cookie_exit_to_a_skip(
|
||||
assert outcome.reason == "cookie-preflight"
|
||||
|
||||
|
||||
def test_command_step_maps_idempotent_replay_exit_to_a_skip(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
class SkippedProcess:
|
||||
pid = 424243
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
del timeout
|
||||
return 76
|
||||
|
||||
monkeypatch.setattr(
|
||||
steps_module,
|
||||
"_start_process",
|
||||
lambda *_args, **_kwargs: (SkippedProcess(), None),
|
||||
)
|
||||
step = CommandStep(argv=("collector.exe",), cwd=tmp_path, env={})
|
||||
|
||||
outcome = step.execute(context=_context(), timeout_seconds=2, dry_run=False)
|
||||
|
||||
assert outcome.exit_code == 76
|
||||
assert outcome.skipped is True
|
||||
assert outcome.reason == "idempotent-replay"
|
||||
|
||||
|
||||
def test_engine_retries_records_every_attempt_and_aggregates_failures(tmp_path: Path) -> None:
|
||||
flaky = FakeStep(
|
||||
StepExecution(exit_code=7, error="temporary"),
|
||||
@@ -528,6 +569,49 @@ def test_effect_ledger_skips_a_successful_production_sink_replay(tmp_path: Path)
|
||||
assert len(action.calls) == 1
|
||||
|
||||
|
||||
def test_repeatable_sink_runs_again_without_business_date_effect_receipt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
action = FakeStep(StepExecution(exit_code=0), StepExecution(exit_code=0))
|
||||
workflow = WorkflowDefinition(
|
||||
"product.tmall_baibu_apply",
|
||||
(
|
||||
StepDefinition(
|
||||
"old_all_bag",
|
||||
action,
|
||||
production_sink=True,
|
||||
replay_policy="repeatable",
|
||||
),
|
||||
),
|
||||
)
|
||||
ledger = EffectLedger(tmp_path)
|
||||
engine = WorkflowEngine(
|
||||
LockManager(tmp_path / "locks"), effect_ledger=ledger
|
||||
)
|
||||
first_context = RunContext.create(
|
||||
"product.tmall_baibu_apply", "2026-07-27", random_suffix="repeatable1"
|
||||
)
|
||||
first = engine.execute(
|
||||
workflow,
|
||||
context=first_context,
|
||||
journal=RunJournal.create(DataLayout(tmp_path), first_context),
|
||||
)
|
||||
second_context = RunContext.create(
|
||||
"product.tmall_baibu_apply", "2026-07-27", random_suffix="repeatable2"
|
||||
)
|
||||
second = engine.execute(
|
||||
workflow,
|
||||
context=second_context,
|
||||
journal=RunJournal.create(DataLayout(tmp_path), second_context),
|
||||
)
|
||||
|
||||
assert first.status == "success"
|
||||
assert second.status == "success"
|
||||
assert second.steps["old_all_bag"].reason is None
|
||||
assert len(action.calls) == 2
|
||||
assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == []
|
||||
|
||||
|
||||
def test_idempotent_sink_runs_again_for_same_business_date(tmp_path: Path) -> None:
|
||||
action = FakeStep(StepExecution(exit_code=0), StepExecution(exit_code=0))
|
||||
workflow = WorkflowDefinition(
|
||||
@@ -593,6 +677,49 @@ def test_skipped_production_sink_cancels_effect_claim_for_future_runs(
|
||||
assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == []
|
||||
|
||||
|
||||
def test_resource_busy_before_action_cancels_effect_claim(tmp_path: Path) -> None:
|
||||
class BusyStepLockManager:
|
||||
def acquire(self, resource, *, owner):
|
||||
del owner
|
||||
if resource == "module:product_commerce:product.daily":
|
||||
raise ResourceBusyError(f"resource is busy: {resource}")
|
||||
return LockManager(tmp_path / "locks").acquire(
|
||||
resource,
|
||||
owner="workflow-lock",
|
||||
)
|
||||
|
||||
action = FakeStep()
|
||||
workflow = WorkflowDefinition(
|
||||
"product.daily",
|
||||
(
|
||||
StepDefinition(
|
||||
"collect",
|
||||
action,
|
||||
resources=("module:product_commerce:product.daily",),
|
||||
production_sink=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
context = RunContext.create(
|
||||
"product.daily",
|
||||
"2026-08-11",
|
||||
random_suffix="busy",
|
||||
)
|
||||
result = WorkflowEngine(
|
||||
BusyStepLockManager(), # type: ignore[arg-type]
|
||||
effect_ledger=EffectLedger(tmp_path),
|
||||
).execute(
|
||||
workflow,
|
||||
context=context,
|
||||
journal=RunJournal.create(DataLayout(tmp_path), context),
|
||||
)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.steps["collect"].reason == "resource-busy-before-execution"
|
||||
assert action.calls == []
|
||||
assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == []
|
||||
|
||||
|
||||
def test_failed_dependency_skips_downstream_critical_step(tmp_path: Path) -> None:
|
||||
downstream = FakeStep()
|
||||
workflow = WorkflowDefinition(
|
||||
|
||||
Reference in New Issue
Block a user