Skip to content

Support stopping async run closures before cancellation compensation starts #238

Closed

Description

@sharosoo

What happens

With Python SDK 1.0.5, cancelling an invocation interrupts await ctx.run(...) with TerminalError("cancelled", 409), but the async closure keeps running while the handler compensates.

An isolated reproduction uses a 20-second async closure, sends cancellation after one second, then runs a 500 ms compensation step. The closure catches asyncio.CancelledError and takes 100 ms to clean up.

Seconds after the cancellation request:

Server Handler catches 409 Compensation returns Closure gets CancelledError Closure cleanup ends
1.7.13 0.014 0.519 0.520 0.620
1.8.0 0.022 0.534 0.534 0.635

The active case sets inactivity timeout to 60 seconds. Reproduced on 1.7.13 and 1.8.0 with the standalone script below:

uv run --no-project --python 3.13 --with restate-sdk==1.0.5 --with hypercorn --with httpx python repro.py

Why it happens

On current main, DoProgressCancelSignalReceived raises the terminal error without cancelling the closure task. Attempt teardown eventually calls Tasks.cancel(), which does not await cleanup. Runtime source is unchanged from 1.0.5.

Question / Request

Could the SDK offer an opt-in, bounded way to cancel in-flight async ctx.run closures and await their cleanup before raising TerminalError("cancelled", 409) in the handler (while still allowing a second cancellation to abort the wait, matching restatedev/restate#5591)?

That would allow async ctx.run closures that opt in to receive asyncio.CancelledError and finish short finally cleanup before the handler's except TerminalError compensation starts, without blocking default cancellation when a closure ignores cancellation.

Standalone reproduction (`repro.py`)
# Run: uv run --no-project --python 3.13 --with restate-sdk==1.0.5 --with hypercorn --with httpx python repro.py
# Requires Docker. Runs both server versions and removes its own containers.
import asyncio
import json
import socket
import subprocess
import time
import uuid
from datetime import timedelta
from importlib.metadata import version

import httpx
import restate
from hypercorn.asyncio import serve
from hypercorn.config import Config

EVENTS = []


def emit(case, event, **fields):
    EVENTS.append(dict(case=case, event=event, t=time.monotonic(), **fields))


def port():
    with socket.socket() as sock:
        sock.bind(('127.0.0.1', 0))
        return sock.getsockname()[1]


active = restate.Service('ActiveClosure', inactivity_timeout=timedelta(seconds=60))
short = restate.Service('ShortClosure', inactivity_timeout=timedelta(milliseconds=200), abort_timeout=timedelta(seconds=60))


async def body(ctx, case):
    emit(case, 'handler_enter')

    async def work():
        emit(case, 'closure_start')
        try:
            await asyncio.sleep(20)
            emit(case, 'closure_complete')
            return 'complete'
        except asyncio.CancelledError:
            emit(case, 'closure_cancelled')
            await asyncio.sleep(0.1)
            emit(case, 'closure_drained')
            raise

    try:
        await ctx.run('work', work)
    except restate.TerminalError as exc:
        emit(case, 'handler_terminal', code=exc.status_code, message=exc.message)

        async def compensate():
            emit(case, 'compensation_start')
            await asyncio.sleep(0.5)
            emit(case, 'compensation_end')
            return 'compensated'

        await ctx.run('compensate', compensate)
        emit(case, 'compensation_returned')
        raise
    finally:
        emit(case, 'handler_finally')
    return 'done'


@active.handler()
async def run(ctx: restate.Context, case: str) -> str:
    return await body(ctx, case)


@short.handler(name='run')
async def short_run(ctx: restate.Context, case: str) -> str:
    return await body(ctx, case)


@active.handler()
async def cancel(ctx: restate.Context, invocation: str) -> str:
    ctx.cancel_invocation(invocation)
    return 'sent'


async def wait_for(predicate, seconds=40):
    async with asyncio.timeout(seconds):
        while not predicate():
            await asyncio.sleep(0.02)


async def test(image):
    EVENTS.clear()
    ingress, admin, fabric, worker = [port() for _ in range(4)]
    name = 'run-closure-repro-' + uuid.uuid4().hex
    command = ['docker', 'run', '--rm', '-d', '--name', name, '--network', 'host']
    for key, value in dict(RESTATE_BIND_IP='127.0.0.1', RESTATE_BIND_PORT=fabric,
                           RESTATE_INGRESS__BIND_PORT=ingress, RESTATE_ADMIN__BIND_PORT=admin,
                           RESTATE_ADVERTISED_ADDRESS=f'http://127.0.0.1:{fabric}/',
                           RESTATE_INGRESS__ADVERTISED_ADDRESS=f'http://127.0.0.1:{ingress}/').items():
        command.extend(['-e', f'{key}={value}'])
    subprocess.run(command + [f'docker.io/restatedev/restate:{image}'], check=True, capture_output=True)
    shutdown = asyncio.Event()
    config = Config()
    config.bind = [f'127.0.0.1:{worker}']
    server = asyncio.create_task(serve(restate.app(services=[active, short]), config, shutdown_trigger=shutdown.wait))
    summaries = []
    try:
        async with httpx.AsyncClient(timeout=45, trust_env=False) as client:
            async with asyncio.timeout(45):
                while True:
                    try:
                        if (await client.get(f'http://127.0.0.1:{admin}/health')).status_code == 200:
                            break
                    except httpx.TransportError:
                        pass
                    await asyncio.sleep(0.1)
            response = await client.post(f'http://127.0.0.1:{admin}/deployments', json={'uri': f'http://127.0.0.1:{worker}'})
            response.raise_for_status()
            for case, service in [('active', 'ActiveClosure'), ('short-inactivity', 'ShortClosure')]:
                response = await client.post(f'http://127.0.0.1:{ingress}/{service}/run/send', json=case)
                response.raise_for_status()
                invocation = response.json()['invocationId']
                await wait_for(lambda: any(e['case'] == case and e['event'] == 'closure_start' for e in EVENTS))
                await asyncio.sleep(1)
                emit(case, 'cancel_sent')
                response = await client.post(f'http://127.0.0.1:{ingress}/ActiveClosure/cancel', json=invocation)
                response.raise_for_status()
                await wait_for(lambda: any(e['case'] == case and e['event'] == 'compensation_returned' for e in EVENTS))
                await asyncio.sleep(0.5)
                rows = [e for e in EVENTS if e['case'] == case]
                first = {}
                for event in rows:
                    first.setdefault(event['event'], event['t'])
                timings = {key: round(value - first['cancel_sent'], 6) for key, value in first.items()}
                terminal = next(e for e in rows if e['event'] == 'handler_terminal')
                if case == 'active':
                    reproduced = first.get('closure_cancelled', 0) > first['compensation_returned']
                else:
                    reproduced = first['handler_terminal'] - first['cancel_sent'] > 10 and 'closure_complete' in first and 'closure_cancelled' not in first
                summary = dict(server=image, sdk=version('restate-sdk'), case=case,
                               code=terminal['code'], message=terminal['message'], seconds_from_cancel=timings,
                               verdict='STILL-REPRODUCES' if reproduced else 'CANNOT-REPRODUCE')
                summaries.append(summary)
                print(json.dumps(summary), flush=True)
    finally:
        shutdown.set()
        try:
            await asyncio.wait_for(server, 10)
        finally:
            subprocess.run(['docker', 'rm', '-f', name], check=False, capture_output=True)
    return summaries


async def main():
    summaries = []
    for image in ['1.7.13', '1.8.0']:
        summaries.extend(await test(image))
    print('VERDICT: ' + ('STILL-REPRODUCES (4/4 cases)' if all(s['verdict'] == 'STILL-REPRODUCES' for s in summaries) else 'CONFLICTING'))


if __name__ == '__main__':
    asyncio.run(main())

Activity

  1. sharosoo commented on Oct 22, 2026

    @sharosoo
    Author

    Closing: in line with the cancellation semantics confirmed in restatedev/restate#5591, run closures should not unconditionally block or shield cancellation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions