# Who owns each resource when cancellation interrupts the happy path?

> Give every resource one owner, define explicit handoffs, and keep cleanup correct when cancellation races with success, failure, or shutdown.

Canonical URL: https://www.devobs.io/articles/qa-ge50-resource-ownership-in-cancellation-paths/
By: Lena Fischer
Published: 2024-05-27T01:26:20.928Z
Updated: 2026-09-06T10:18:15.722Z
Section: Architecture

Cancellation-safe code needs one rule above all others: at any moment, every lock, transaction, stream, child task, and partial output has exactly one owner responsible for releasing it, finishing it, or discarding it. Cancellation must change control flow without making ownership ambiguous. If you cannot point to the current owner and the exact transfer event, you will eventually leak resources, double-clean them, or let orphaned work keep mutating state after the caller has stopped waiting.

## What ownership rule survives cancellation races?

Use a single-owner invariant. The code that acquires a resource owns cleanup until a later step transfers that duty explicitly. That transfer must happen at an enforced boundary: scope exit, successful commit, durable enqueue, or publication of a handle another component can now observe.

This matches the cancellation models in common runtimes. In Go, request work often fans out into child goroutines, and when the request is canceled those goroutines should stop quickly; the same article also explains that sub-operations should not cancel their parent [Go context article](https://go.dev/blog/context). In .NET, the requester issues cancellation, but each listener must notice it and respond appropriately, which is exactly the split between cancellation authority and cleanup responsibility in [Cancellation in Managed Threads](https://learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads).

## Which resources should be unwound locally, and which need a publish boundary?

Locks, file handles, DB transactions, and in-memory streams are local resources. Their owner should release them in the same scope that acquired them, with `defer`, `using`, or equivalent structured cleanup. On cancellation, unwind immediately.

Partial outputs are different because another system may already observe them. A half-uploaded object, sent email, or emitted message is not a local resource you can pretend never happened. Here the key design choice is the publish boundary. Write to a temporary object key, staging table, or draft record first. Only publish the durable reference after the underlying work has completed successfully. Publishing only after completion keeps cancellation from exposing incomplete state.

Child tasks deserve their own rule: if the caller starts them, the caller owns them until they are joined or detached to a supervisor. Python’s documentation is direct on both points: keep a reference to created tasks, and prefer `TaskGroup` for related work with scope-bound waiting and cleanup [Python asyncio tasks documentation](https://docs.python.org/3/library/asyncio-task.html).

## How does this look in a real request path?

Consider: request handler -> SQL transaction -> blob upload -> thumbnail worker.

The handler owns the request context and its cancellation function. In Go, canceling that derived context also releases its timer resources, so the handler should always call the cancel function on exit [Go context article](https://go.dev/blog/context).

The function that began the transaction owns rollback until commit succeeds. Passing the transaction object to helpers does not transfer ownership.

The upload helper owns the network stream while bytes are in flight. It writes to a temporary blob key. If cancellation arrives mid-upload, that helper cleans up the temp object if possible, or leaves it unreferenced for later lifecycle cleanup. It must not publish the final database pointer before the object exists.

After upload success, the transaction owner stores the final blob reference and commits. Only then has ownership of that partial output moved from temporary staging into durable application state.

If thumbnail generation must continue after the client disconnects, the request should first persist a job record. Before that durable enqueue commits, the request still owns the work and must cancel it. After commit, ownership transfers to the worker system, which now owns retries, cancellation, and cleanup.

## How do you verify the design?

Use a short checklist in API review: who owns this resource now, what exact event transfers ownership, and who cleans up if cancellation lands one instruction before or after that event? Then write fault-injection tests around timeout, shutdown, and success races.

**What about double cleanup?** Make cleanup idempotent where practical, but still keep one logical owner.

**Should canceling the wait always cancel the work?** No. Sometimes the caller should stop waiting while a transferred owner continues.

Next step: pick one timeout-prone path in your service and annotate each resource with owner, transfer event, and cleanup action before changing code.

Reviewed: 2026-09-05

## Source references

- <https://go.dev/blog/context>
- <https://docs.python.org/3/library/asyncio-task.html>
- <https://learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads>
