diff --git a/docs/modal-compatibility-matrix.md b/docs/modal-compatibility-matrix.md index 49cf337..94165bd 100644 --- a/docs/modal-compatibility-matrix.md +++ b/docs/modal-compatibility-matrix.md @@ -74,9 +74,9 @@ real gap โ€” should not stay this way) ยท โฌœ not present at all. | `@modal.enter(snap=False)` | Runs once per container at startup, before any input. `snap=True` marks pre-snapshot code (see ยงI memory snapshots). | ๐Ÿ”ฅ (wherever `@cls` is used) | โœ… body carried as `ir.Class.EnterBody`, actually run once by `warmd`. `snap=` kwarg itself unrecognized (falls through to generic "unmodeled arg"). | Memory-snapshot semantics (`snap=True` vs default) aren't distinguished โ€” low-risk since calque doesn't do container snapshotting at all. | โ€” | | `@modal.exit()` | Runs on container shutdown; gets a grace period on preemption specifically for cleanup. | ๐ŸŸก (paired with `@enter` wherever teardown matters) | โœ… (fixed 2026-08-07, calque#86) recognized in `visit_ClassDef`, excluded from `cls.Methods` (confirmed via a live repro: before the fix, an exit-only class was picked as the warm unit's sole method by `pickWarmUnit`'s fallback; after the fix, `run()` correctly refuses with "no mapped @cls+@enter warm unit found" instead). Teardown itself is leaked as unreproduced (`ir.Class.HasExit`). | The warm supervisor has no shutdown-hook concept โ€” teardown logic itself still doesn't run, just no longer silently misclassified. | closed | | `@modal.method(is_generator=None)` | Converts an instance method into an invokable Modal Function scoped to the class. | ๐Ÿ”ฅ (wherever `@cls` is used) | โœ… | `is_generator=` kwarg not specifically recognized. | โ€” | -| `@modal.batched(max_batch_size=, wait_ms=)` | Dynamic input batching; all inputs/outputs must be equal-length lists; at most one batched method per class. | ๐ŸงŠ (2/212 files in modal-examples; not found in independent-repo sample) | โฌœ | โ€” | [#91](https://github.com/spore-host/calque/issues/91) | +| `@modal.batched(max_batch_size=, wait_ms=)` | Dynamic input batching; all inputs/outputs must be equal-length lists; at most one batched method per class. | ๐ŸงŠ (2/212 files in modal-examples; not found in independent-repo sample) | ๐ŸŸจ recognized, not modeled โ€” detected via trailing decorator name (same pattern as `_SERVE_DECOS`), tagged with a distinct `modal.batched` leak in `_describe_fn`; the function/method still runs, just without Modal's request-coalescing behavior. | Real batching execution (coalescing N concurrent calls into one list-valued call) is not reproduced โ€” out of scope per this construct's rare (๐ŸงŠ) frequency. | [#91](https://github.com/spore-host/calque/issues/91) | | `@modal.concurrent(max_inputs=, target_inputs=None)` | **Replaces the deprecated `allow_concurrent_inputs=N` kwarg** (v0.73.148) โ€” now a separate decorator, not a function kwarg. Sync functions get separate OS threads (must be thread-safe); async get coroutines on one thread. | ๐ŸŸก (a common tuning pattern in production) | โœ… (fixed 2026-08-07, calque#82) `_describe_fn` already captured every decorator's kwargs on a plain function, so `max_inputs`/`target_inputs` just needed adding to `autoscalingKwargs`. The class-level case needed a real fix: `visit_ClassDef` only read `@app.cls`'s OWN kwargs โ€” a separate `@modal.concurrent(...)` stacked on the same class was invisible; now merged into `cls_kwargs`. | โ€” | closed | -| `@app.batched`/`max_batch_size=` | See `@modal.batched` above (same construct, different framing in the original audit). | ๐ŸงŠ | โฌœ | โ€” | dup of above | +| `@app.batched`/`max_batch_size=` | See `@modal.batched` above (same construct, different framing in the original audit). | ๐ŸงŠ | ๐ŸŸจ see `@modal.batched` above โ€” same trailing-name match covers this spelling too. | โ€” | dup of above | | Web decorators: `@modal.fastapi_endpoint` (renamed from `@modal.web_endpoint`, v0.73.89), `@modal.asgi_app()`, `@modal.wsgi_app()`, `@modal.web_server(port)` | Long-lived, request-driven, no fixed N, autoscaling-driven termination โ€” fundamentally different execution model from batch `.map()`. | ๐ŸŸก (a first-class use case โ€” own top-level directory in modal-examples, ~19% of files) | ๐ŸŸจ detected via `_SERVE_DECOS` (matches both old and new decorator names by trailing attribute), sets `entry_kind: "serve"`; `run.go` refuses gracefully with a leak, the long-lived server is never built (by design โ€” see `docs/serve-architecture.md`). | Working as intended per the project's own documented scope decision. | โ€” | --- diff --git a/internal/parse/parse_test.go b/internal/parse/parse_test.go index f094951..ca7055b 100644 --- a/internal/parse/parse_test.go +++ b/internal/parse/parse_test.go @@ -1158,6 +1158,38 @@ func TestParseRareConstructsAreTaggedNotSilent(t *testing.T) { } } +// TestParseModalBatchedDecoratorLeaks (calque#91): @modal.batched(...) had +// ZERO recognition at all before this fix โ€” unlike its four from_name/ +// CloudBucketMount siblings tested above (TestParseRareConstructsAreTaggedNotSilent), +// it fell through completely unnoticed, no leak, no tag. Proves it now gets +// the SAME distinguishable leak treatment, AND that the leak is purely +// additive: the decorated function still resolves normally (still findable, +// still runnable) โ€” batching itself is out of scope, only the leak is new. +func TestParseModalBatchedDecoratorLeaks(t *testing.T) { + r, args := runner(t) + rep := &leak.Report{} + script, _ := filepath.Abs("../../testdata/scripts/batched_function.py") + + app, err := Parse(context.Background(), script, rep, r, args...) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + found := false + for _, l := range rep.Leaks { + if strings.Contains(l.Detail, "modal.batched") && (strings.Contains(l.Detail, "batching") || strings.Contains(l.Detail, "coalescing")) { + found = true + } + } + if !found { + t.Errorf("expected a leak mentioning modal.batched + batching/coalescing; leaks=%+v", rep.Leaks) + } + + if _, ok := app.FindFunction("process"); !ok { + t.Error(`function "process" not found in parsed app โ€” the @modal.batched leak must be additive, not a refusal to parse the function`) + } +} + // TestParseMapIterables (calque#136): a real .map()/.starmap() iterable that // pyast could statically resolve (a literal list, a literal list of tuples, // or a range()) must land in ir.Function.Items; an unresolvable one (a diff --git a/testdata/scripts/batched_function.py b/testdata/scripts/batched_function.py new file mode 100644 index 0000000..296cce5 --- /dev/null +++ b/testdata/scripts/batched_function.py @@ -0,0 +1,20 @@ +"""batched_function.py โ€” calque#91 fixture: @modal.batched(...) stacked on a +plain @app.function, Modal's automatic request-coalescing decorator. + +Before this fixture existed, @modal.batched had ZERO recognition at all โ€” +unlike its four from_name/CloudBucketMount siblings in rare_constructs.py, +it fell through completely unnoticed (no leak, no tag). This fixture only +proves the decorator is now a distinct, greppable leak (`where` == +"modal.batched" in helper_leaks) โ€” batching itself is NOT modeled; `process` +still runs, just without Modal's list-coalescing behavior. +""" + +import modal + +app = modal.App("batched-function") + + +@app.function() +@modal.batched(max_batch_size=4, wait_ms=100) +def process(items: list[int]) -> list[int]: + return [i * 2 for i in items] diff --git a/tools/pyast/pyast.py b/tools/pyast/pyast.py index b6bfded..8af71a5 100755 --- a/tools/pyast/pyast.py +++ b/tools/pyast/pyast.py @@ -651,6 +651,28 @@ def _describe_fn( "lineno": getattr(d, "lineno", node.lineno), } ) + # calque#91: @modal.batched(...) requests automatic request-coalescing โ€” + # it's a MODIFIER stacked on top of whatever shape the function already + # has (a plain @app.function, an @app.cls method, etc.), not a new + # entry_kind value (unlike _SERVE_DECOS/_entry_kind above). Before this, + # it fell through completely unnoticed โ€” zero leak at all, unlike its + # four from_name/CloudBucketMount siblings above, which already get a + # distinguishable "where" tag. Matched by trailing decorator name, same + # style as the @app.function check in _try_expand_decorated_loop. + if leaks is not None: + for d in node.decorator_list: + if _decorator_name(d).rsplit(".", 1)[-1] == "batched": + leaks.append( + { + "where": "modal.batched", + "detail": ( + "@modal.batched(...) requests automatic request-coalescing/batching of concurrent " + "calls into one call with list-valued args; calque does not reproduce this -- the " + "function still runs, just without Modal's batching behavior" + ), + "lineno": getattr(d, "lineno", node.lineno), + } + ) return { "name": node.name, "lineno": node.lineno,