#2408·cocoindex

[BUG] `@coco.fn(batching=True)` / `@coco.fn(runner=...)` rejects both async and sync functions; the documented async form cannot be used

Author: hardness1020Created Sep 13, 2026Updated Sep 13, 2026

Describe the bug

  • With batching=True, runner=..., or max_batch_size=..., @coco.fn picks _SyncFunctionBuilder.
  • That builder rejects async def ("use @coco.fn.as_async"). For sync def, _build_sync rejects batching / runner ("must be async"). No input passes.
  • Docs, docstring, and types all say the async form works:
    • docs/src/content/docs/programming_guide/function.mdx:293: "If the underlying function is already async def, @coco.fn(batching=True) works directly."
    • function.mdx:325: example @coco.fn(batching=True, max_batch_size=64) on async def embed(...). Raises at import when copied verbatim.
    • function.mdx:357: same claim for runner: "If the underlying function is already async def, @coco.fn(runner=...) works directly."
    • python/cocoindex/_internal/function.py:2063-2064 docstring: "only async underlying functions are accepted when batching/runner is specified".
    • _AsyncBatchedDecorator (function.py:114-138) and the overload at :1969-1981. mypy types the docs example as AsyncFunction[[str], list[float]], then runtime raises.
  • Side effect: @coco.fn(max_batch_size=32) on sync def builds a plain SyncFunction and silently drops the option; on async def it raises the misleading "Async functions are not supported" message.
  • All in-repo callers use @coco.fn.as_async(...) (ops/sentence_transformers.py, ops/litellm.py, tests/core/test_function_batching.py), so CI never hits it. Users following the docs do.

To Reproduce

python
import cocoindex as coco


@coco.fn(batching=True, max_batch_size=64)
async def embed(texts: list[str]) -> list[list[float]]:
    return [[float(len(t))] for t in texts]
ValueError: Async functions are not supported by @coco.fn decorator when batching or runner is specified. Please use @coco.fn.as_async instead.

Matrix on main:

fn(batching=True)                   on async def -> ValueError (above)
fn(batching=True, max_batch_size=4) on async def -> ValueError (above)
fn(runner=coco.GPU)                 on async def -> ValueError (above)
fn(batching=True)                   on sync def  -> ValueError: Batching and runner require the function to be async. Use @coco.fn.as_async instead, or rewrite the function to be async.
fn(runner=coco.GPU)                 on sync def  -> same
fn(max_batch_size=32)               on sync def  -> SyncFunction, max_batch_size dropped
fn.as_async(batching=True)          on async def -> works
fn.as_async(batching=True)          on sync def  -> works

Expected behavior

  • @coco.fn(batching=True, ...) and @coco.fn(runner=...) accept async def and build an AsyncFunction, same as @coco.fn.as_async.
  • Sync def keeps raising the existing "require the function to be async" message.
  • max_batch_size without batching=True is out of scope. It is ignored today for both fn and fn.as_async (:1668 forces max_batch_size=1 when not batching); making it raise is a separate API strictness call.

Root cause

python/cocoindex/_internal/function.py:

  • :2083: _SyncFunctionBuilder(...) if batching or runner or max_batch_size is not None else _AutoFunctionBuilder(...).
  • :1889-1896: _SyncFunctionBuilder.__call__ raises on inspect.iscoroutinefunction(fn).
  • :1848-1853: _build_sync raises if batching or runner. The two checks are mutually exclusive.
  • :1865-1886: _build_async already handles batching and runner; as_async uses it.

History:

  • #1634 (1efd81d5) added _SyncFunctionBuilder, the selection clause, and both error strings. Its docstring said "only sync underlying functions are accepted", already contradicting _build_sync.
  • #1708 (bb100f2b, async-first API) flipped the docstring to "only async" and wrote the "works directly" docs line, but left the builder unchanged.
  • #2270 added the docs example at :325.
  • No test has ever asserted the rejection. Latent bug from #1634, not a design decision.

Proposed direction

  • Replace _SyncFunctionBuilder with an async-only builder: non-coroutine input raises the existing "require the function to be async" message, otherwise return self._build_async(fn). Single overload AsyncCallable[P, R] -> AsyncFunction[P, R] so mypy rejects sync input.
  • Point the runner= overload at :1995 to that builder. It currently returns _SyncFunctionBuilder and types an async function as SyncFunction[..., Coroutine[...]].
  • Delete the if self._batching or self._runner is not None guard in _build_sync (:1849-1853); unreachable once _AutoFunctionBuilder is the only caller.
  • Docs: function.mdx:293 and :325 become correct as written. skills/cocoindex/SKILL.md:98 should say the underlying function must be async def, or use @coco.fn.as_async.
  • Behavior change to note in the PR: @coco.fn(max_batch_size=32) on sync def goes from silent drop to "require the function to be async", since max_batch_size is not None is in the selection condition. Already meaningless input; no in-repo caller does it.
  • No cache, fingerprint, or version bump. Decoration-time only.

Maintainer decision needed: which spelling?

  • A (recommended): fix the code. Plain @coco.fn(batching=True) / @coco.fn(runner=...) accepts async def, as above. #1708 and the existing overloads already commit to this.
  • B: single spelling. Keep the plain form unsupported; @coco.fn.as_async is the only batching/runner form. Docs-and-types only: drop the sentence at function.mdx:293, switch the :325 example to as_async, fix the docstring at :2063, remove the batching=True / runner= overloads on fn.

Say A or B and I'll send the PR.

Validated on a patched copy:

  • @coco.fn(batching=True, max_batch_size=4) on async def, 10 concurrent calls: correct results, batches [4, 4, 1, 1].
  • @coco.fn(runner=coco.GPU) on async def runs.
  • Batched method with max_batch_size=3: batches [1, 3, 3].
  • Sync def still rejected.
  • test_function_batching, test_function_memo, test_function_misc, test_gpu_pool: 103 passed, same as baseline.

Tests

  • python/tests/core/test_function_batching.py: plain @coco.fn(batching=True) and @coco.fn(batching=True, max_batch_size=N) on async def, concurrent calls, assert results and batch sizes.
  • test_gpu_pool.py or test_function_misc.py: plain @coco.fn(runner=coco.GPU) on async def.
  • Sync def with batching=True / runner= still raises ValueError.
  • Batched async method with self through plain @coco.fn(batching=True, max_batch_size=N).

Related

  • #1634 introduced the builder; #1708 wrote the docs claim; #2270 added the failing docs example.
  • #2275 (call-site memoization config) touches the same decorator surface but is unrelated.

CocoIndex Version

main at 75610ca4. function.py unchanged since 1.0.21.

I'd like to work on this if the direction sounds right.