Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/modal-compatibility-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | — |

---
Expand Down
32 changes: 32 additions & 0 deletions internal/parse/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions testdata/scripts/batched_function.py
Original file line number Diff line number Diff line change
@@ -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]
22 changes: 22 additions & 0 deletions tools/pyast/pyast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading