Skip to content

Commit 914ea05

Browse files
committed
fix: Defer forwardable action fields to run time; cache key and lint fixes
Three fixes: 1. Defer run-time-resolvable action fields to run time, matching openjd-rs (job/create_job/instantiate.rs clones timeout and cancelation unresolved into the Job; the session resolves them right before the action runs). Previously Action.timeout and the cancelation classes' notifyPeriodInSeconds were in resolve_fields, so create_job resolved them at job creation. A wrap hook forwarding "{{WrappedAction.Timeout}}" or "{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}" (RFC 0008 round-trip forwarding) decoded cleanly but crashed create_job with "Undefined variable" because those symbols only exist at run time. The fields now carry their raw FormatStrings through job instantiation; validation still happens at TEMPLATE scope at decode time (unchanged, matching openjd-rs format_strings.rs). Deliberate behavior change: a plain FEATURE_BUNDLE_1 template with timeout "{{Param.X}}" now carries the FormatString into the Job instead of resolving it at creation — resolution moves to the session, as in openjd-rs. The sessions runtime already resolves FormatString timeouts/notify periods at run time (_resolve_action_timeout, resolve_effective_cancelation). Fixes the wrap-cancelation-roundtrip-job-environment conformance fixture, which failed at create_job for exactly this bug. 2. Fix a typed/untyped cache-key collision in symtab_to_expr_values: priming the engine-table cache with types=None on a symbol table whose expr_types is non-empty cached an untyped table that a later typed call at the same mutation version was served, losing the type coercions. The cache condition is now strict: cacheable iff types is the symtab's own expr_types, or types is None and the symtab has no expr_types. 3. Module-level Path import in symbol-table cache tests (ruff F821/F401), retained from the previous revision of this commit. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 1deda17 commit 914ea05

5 files changed

Lines changed: 220 additions & 19 deletions

File tree

src/openjd/model/_format_strings/_expr_support.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,15 @@ def symtab_to_expr_values(
8383
embedded-file ``data``) against one unchanged table — without the cache
8484
the whole table is rebuilt across the Rust boundary per expression.
8585
Caching requires ``types`` to be the symbol table's own ``expr_types``
86-
(or ``None``), which is what the evaluation layer passes; any other
87-
``types`` object bypasses the cache.
86+
(which is what the evaluation layer passes), or ``None`` when the symbol
87+
table has no ``expr_types``; any other combination bypasses the cache.
88+
In particular an untyped (``types=None``) build on a symbol table that
89+
*has* ``expr_types`` is never cached — otherwise a later typed call at
90+
the same mutation version would be served the stale untyped table and
91+
lose the type coercions (typed/untyped cache-key collision).
8892
"""
89-
cacheable = types is None or types is getattr(symtab, "expr_types", None)
93+
symtab_expr_types = getattr(symtab, "expr_types", None)
94+
cacheable = types is symtab_expr_types or (types is None and not symtab_expr_types)
9095
version = getattr(symtab, "_version", None)
9196
cache_key = ("engine_table", str(path_format))
9297
if cacheable and version is not None:

src/openjd/model/v2023_09/_model.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,13 @@ class CancelationMethodNotifyThenTerminate(OpenJDModel_v2023_09):
344344
mode: Literal[CancelationMode.NOTIFY_THEN_TERMINATE]
345345
notifyPeriodInSeconds: Optional[Union[NotifyPeriodType, FormatString]] = None # noqa: N815
346346

347-
_job_creation_metadata = JobCreationMetadata(resolve_fields={"notifyPeriodInSeconds"})
347+
# A FormatString notifyPeriodInSeconds is NOT resolved at job creation:
348+
# it is carried through unresolved and resolved at run time by the
349+
# session, matching openjd-rs (job/create_job/instantiate.rs clones the
350+
# cancelation object unresolved into the Job). This is what lets RFC 0008
351+
# wrap hooks forward "{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}",
352+
# whose symbols only exist at run time.
353+
_job_creation_metadata = JobCreationMetadata()
348354

349355
@field_validator("notifyPeriodInSeconds", mode="before")
350356
@classmethod
@@ -432,7 +438,13 @@ class CancelationMethodDeferred(OpenJDModel_v2023_09):
432438
mode: FormatString
433439
notifyPeriodInSeconds: Optional[Union[NotifyPeriodType, FormatString]] = None # noqa: N815
434440

435-
_job_creation_metadata = JobCreationMetadata(resolve_fields={"notifyPeriodInSeconds"})
441+
# Neither `mode` nor a FormatString `notifyPeriodInSeconds` is resolved
442+
# at job creation: the whole deferred cancelation object is carried
443+
# through unresolved and resolved at run time by the session, matching
444+
# openjd-rs (job/create_job/instantiate.rs). Run-time-only symbols such
445+
# as WrappedAction.Cancelation.* are how RFC 0008 wrap hooks forward the
446+
# wrapped action's cancelation.
447+
_job_creation_metadata = JobCreationMetadata()
436448

437449
@field_validator("mode", mode="before")
438450
@classmethod
@@ -584,16 +596,23 @@ class Action(OpenJDModel_v2023_09):
584596
timeout: Optional[Union[PositiveInt, FormatString]] = None
585597
cancelation: Optional[CancelationMethod] = None
586598

587-
_job_creation_metadata = JobCreationMetadata(resolve_fields={"timeout"})
599+
# A FormatString `timeout` is NOT resolved at job creation: it is carried
600+
# through job instantiation unresolved and resolved at run time by the
601+
# session, matching openjd-rs (job/create_job/instantiate.rs clones
602+
# timeout and cancelation unresolved into the Job, and the session
603+
# resolves them right before the action runs). This is what lets RFC 0008
604+
# wrap hooks forward "{{WrappedAction.Timeout}}" — those symbols only
605+
# exist at run time.
606+
_job_creation_metadata = JobCreationMetadata()
588607

589608
# `timeout` and `cancelation` (its notifyPeriodInSeconds and a deferred
590-
# format-string mode) are plain @fmtstring fields resolved at job
591-
# creation, before any session exists — unlike `command`/`args`, which
592-
# resolve in the session. They therefore validate at template scope: no
593-
# Session.*, no Env.File.*/Task.File.*, and no host-context functions.
594-
# The RFC 0008 wrap hooks may still forward the wrapped action's values
595-
# ("{{WrappedAction.Timeout}}"): the WrappedAction.* symbols are injected
596-
# per-hook at every scope via EnvironmentActions._template_field_inject.
609+
# format-string mode) still VALIDATE at template scope: no Session.*, no
610+
# Env.File.*/Task.File.*, and no host-context functions (matching
611+
# openjd-rs format_strings.rs). The RFC 0008 wrap hooks may forward the
612+
# wrapped action's values ("{{WrappedAction.Timeout}}"): the
613+
# WrappedAction.* symbols are injected per-hook at every scope via
614+
# EnvironmentActions._template_field_inject, and openjd-rs's
615+
# symtab-filter preserves them for run time.
597616
_template_field_scopes = {
598617
"timeout": ResolutionScope.TEMPLATE,
599618
"cancelation": ResolutionScope.TEMPLATE,

test/openjd/model_v0/test_symbol_table_cache.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
``expr_host_rules``) invalidates the cache.
1111
"""
1212

13+
from pathlib import Path
14+
1315
import pytest
1416

1517
from openjd.model import SymbolTable
@@ -68,6 +70,42 @@ def test_expr_types_update_invalidates(self) -> None:
6870
second = symtab_to_expr_values(symtab, types=symtab.expr_types or None)
6971
assert first is not second
7072

73+
def test_untyped_prime_does_not_poison_typed_call(self) -> None:
74+
# Regression: priming the cache with types=None on a symbol table
75+
# whose expr_types is non-empty must NOT cache an untyped engine
76+
# table — a later typed call at the same mutation version would be
77+
# served the stale untyped table and lose the type coercions.
78+
from openjd.expr import ExprProfile
79+
80+
from openjd.model._format_strings._expr_support import parse_expr_or_raise
81+
82+
symtab = SymbolTable()
83+
symtab["Param.X"] = "10"
84+
symtab.expr_types["Param.X"] = "INT"
85+
86+
# WHEN: prime with an untyped build on the typed symtab.
87+
untyped = symtab_to_expr_values(symtab, types=None)
88+
89+
# THEN: the subsequent typed call is not served the stale table...
90+
typed = symtab_to_expr_values(symtab, types=symtab.expr_types or None)
91+
assert typed is not untyped
92+
93+
# ...and coerces correctly: arithmetic sees the int 10, not "10".
94+
parsed = parse_expr_or_raise("Param.X + 1")
95+
result = parsed.evaluate(values=typed, profile=ExprProfile.current())
96+
assert result.item() == 11
97+
98+
# The typed build is the one cached for the standard call form.
99+
assert symtab_to_expr_values(symtab, types=symtab.expr_types or None) is typed
100+
101+
def test_untyped_call_still_cached_when_symtab_has_no_types(self) -> None:
102+
# types=None on a symtab without expr_types remains cacheable (this
103+
# is the evaluation layer's call form for untyped tables).
104+
symtab = SymbolTable()
105+
symtab["Param.X"] = "10"
106+
first = symtab_to_expr_values(symtab, types=symtab.expr_types or None)
107+
assert symtab_to_expr_values(symtab, types=symtab.expr_types or None) is first
108+
71109
def test_foreign_types_mapping_bypasses_cache(self) -> None:
72110
# A caller-supplied types mapping that is not the table's own
73111
# expr_types must not poison or consult the cache.
@@ -158,12 +196,11 @@ def test_profile_cached_and_invalidated_by_host_rules(self) -> None:
158196

159197
class TestTypedResolutionSingleEvaluation:
160198
def test_range_expression_evaluated_once_per_definition(
161-
self, monkeypatch: pytest.MonkeyPatch, tmp_path: "Path"
199+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
162200
) -> None:
163201
"""RFC 0006 typed whole-field range resolution: the expression is
164202
evaluated exactly once per task-parameter definition — the create_as
165203
target-model decision and the field value share the result."""
166-
from pathlib import Path
167204

168205
from openjd.model import create_job, decode_job_template, preprocess_job_parameters
169206
from openjd.model._internal import _create_job as internal_create_job

test/openjd/model_v0/v2023_09/test_feature_bundle_1.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,94 @@ def test_amount_requirement_min_greater_than_max_resolved_fails(self) -> None:
620620
)
621621
assert "max" in str(excinfo.value).lower()
622622

623+
def test_timeout_and_notify_period_carried_through_unresolved(self) -> None:
624+
"""timeout and notifyPeriodInSeconds format strings are NOT resolved
625+
at job creation: they are carried through the instantiated Job as raw
626+
FormatStrings and resolved at run time by the session, matching
627+
openjd-rs (job/create_job/instantiate.rs clones timeout+cancelation
628+
unresolved into the Job). This is what allows RFC 0008 wrap hooks to
629+
forward run-time-only symbols such as WrappedAction.Timeout."""
630+
from openjd.model import create_job, decode_job_template
631+
from openjd.model._format_strings import FormatString
632+
from openjd.model._types import ParameterValue, ParameterValueType
633+
634+
template = decode_job_template(
635+
template={
636+
"specificationVersion": "jobtemplate-2023-09",
637+
"extensions": ["FEATURE_BUNDLE_1"],
638+
"name": "Test",
639+
"parameterDefinitions": [{"name": "TO", "type": "INT", "default": 30}],
640+
"steps": [
641+
{
642+
"name": "Step1",
643+
"script": {
644+
"actions": {
645+
"onRun": {
646+
"command": "echo",
647+
"timeout": "{{Param.TO}}",
648+
"cancelation": {
649+
"mode": "NOTIFY_THEN_TERMINATE",
650+
"notifyPeriodInSeconds": "{{Param.TO}}",
651+
},
652+
}
653+
}
654+
},
655+
}
656+
],
657+
},
658+
supported_extensions=[ExtensionName.FEATURE_BUNDLE_1],
659+
)
660+
661+
# WHEN
662+
job = create_job(
663+
job_template=template,
664+
job_parameter_values={"TO": ParameterValue(type=ParameterValueType.INT, value="30")},
665+
)
666+
667+
# THEN: creation succeeds and both fields carry the raw FormatString.
668+
on_run = job.steps[0].script.actions.onRun
669+
assert isinstance(on_run.timeout, FormatString)
670+
assert str(on_run.timeout) == "{{Param.TO}}"
671+
assert on_run.cancelation is not None
672+
assert isinstance(on_run.cancelation.notifyPeriodInSeconds, FormatString)
673+
assert str(on_run.cancelation.notifyPeriodInSeconds) == "{{Param.TO}}"
674+
675+
def test_literal_timeout_and_notify_period_stay_ints_through_create_job(self) -> None:
676+
"""Literal integer timeout/notifyPeriodInSeconds are unaffected by
677+
the run-time deferral of format-string values."""
678+
from openjd.model import create_job, decode_job_template
679+
680+
template = decode_job_template(
681+
template={
682+
"specificationVersion": "jobtemplate-2023-09",
683+
"extensions": ["FEATURE_BUNDLE_1"],
684+
"name": "Test",
685+
"steps": [
686+
{
687+
"name": "Step1",
688+
"script": {
689+
"actions": {
690+
"onRun": {
691+
"command": "echo",
692+
"timeout": 30,
693+
"cancelation": {
694+
"mode": "NOTIFY_THEN_TERMINATE",
695+
"notifyPeriodInSeconds": 15,
696+
},
697+
}
698+
}
699+
},
700+
}
701+
],
702+
},
703+
supported_extensions=[ExtensionName.FEATURE_BUNDLE_1],
704+
)
705+
job = create_job(job_template=template, job_parameter_values={})
706+
on_run = job.steps[0].script.actions.onRun
707+
assert on_run.timeout == 30
708+
assert on_run.cancelation is not None
709+
assert on_run.cancelation.notifyPeriodInSeconds == 15
710+
623711

624712
class TestAmountRequirementEdgeCases:
625713
"""Edge case tests for AmountRequirementTemplate."""

test/openjd/model_v0/v2023_09/test_wrap_actions.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,57 @@ def test_create_job_with_step_env_wrap_succeeds(self):
295295
step_env_actions = job.steps[0].stepEnvironments[0].script.actions
296296
assert step_env_actions.onWrapTaskRun is not None
297297

298+
def test_create_job_with_full_forwarding_defers_runtime_fields(self):
299+
# Regression: a wrap hook forwarding the wrapped action's timeout and
300+
# cancelation ("{{WrappedAction.Timeout}}",
301+
# "{{WrappedAction.Cancelation.*}}") decoded cleanly but crashed
302+
# create_job with "Undefined variable" — those symbols only exist at
303+
# run time. Like openjd-rs (job/create_job/instantiate.rs clones
304+
# timeout+cancelation unresolved into the Job), the fields must be
305+
# carried through job instantiation as raw FormatStrings and left for
306+
# the session to resolve.
307+
from openjd.model._format_strings import FormatString
308+
309+
hook = {
310+
"command": "{{WrappedAction.Command}}",
311+
"args": ["{{WrappedAction.Args}}"],
312+
"timeout": "{{WrappedAction.Timeout}}",
313+
"cancelation": {
314+
"mode": "{{WrappedAction.Cancelation.Mode}}",
315+
"notifyPeriodInSeconds": ("{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}"),
316+
},
317+
}
318+
wrap_env = {
319+
"name": "W",
320+
"script": {
321+
"actions": {
322+
"onWrapEnvEnter": dict(hook),
323+
"onWrapTaskRun": dict(hook),
324+
"onWrapEnvExit": dict(hook),
325+
}
326+
},
327+
}
328+
exts = ("WRAP_ACTIONS", "EXPR", "FEATURE_BUNDLE_1")
329+
jt = _decode_job(_job_template(job_environments=[wrap_env], extensions=exts))
330+
331+
# WHEN
332+
job = create_job(job_template=jt, job_parameter_values={})
333+
334+
# THEN: creation succeeds and every run-time-resolvable field on the
335+
# instantiated hook action is the raw, unresolved FormatString.
336+
action = job.jobEnvironments[0].script.actions.onWrapTaskRun
337+
assert isinstance(action.timeout, FormatString)
338+
assert str(action.timeout) == "{{WrappedAction.Timeout}}"
339+
assert isinstance(action.cancelation.mode, FormatString)
340+
assert str(action.cancelation.mode) == "{{WrappedAction.Cancelation.Mode}}"
341+
assert isinstance(action.cancelation.notifyPeriodInSeconds, FormatString)
342+
assert (
343+
str(action.cancelation.notifyPeriodInSeconds)
344+
== "{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}"
345+
)
346+
assert isinstance(action.command, FormatString)
347+
assert str(action.command) == "{{WrappedAction.Command}}"
348+
298349

299350
class TestCancelationRoundTripForwarding:
300351
"""Cancelation round-trip forwarding (openjd-rs PR #261 review,
@@ -314,10 +365,11 @@ class TestCancelationRoundTripForwarding:
314365
Mirrors ``cancelation_round_trip_*`` in openjd-rs
315366
``crates/openjd-model/tests/integration/test_wrap_actions.rs``.
316367
317-
Note: the review example also forwards ``timeout:``; that is
318-
deliberately out of scope pending the WrappedAction.Timeout
319-
int-vs-int? question (the 0-when-unset sentinel is not a valid
320-
``<posinteger>``).
368+
Note: the review example also forwards ``timeout:``; that works too —
369+
see TestWrapActionsCreateJob
370+
.test_create_job_with_full_forwarding_defers_runtime_fields, which
371+
covers full forwarding (command/args/timeout/cancelation) through
372+
create_job.
321373
"""
322374

323375
_ROUND_TRIP_EXTS = ["WRAP_ACTIONS", "EXPR", "FEATURE_BUNDLE_1"]

0 commit comments

Comments
 (0)