Owner-bound asyncio workers: cancel siblings and await cleanup

Owner-bound asyncio workers: cancel siblings and await cleanup

calendar_today ago • schedule2 min read
— Originally published at chriseugenerodriguez.com

Prerequisites: familiarity with Python asyncio and, for the runnable analogy below, Node.js. Start with the lifetime of the operation: should a failure stop related tasks, or should independent work continue under a different owner? Python TaskGroup provides structured task ownership; ordinary gather aggregates outcomes without automatically cancelling siblings when one raises. Those Python rules are documented by Python, not established by the JavaScript fixture.

The runnable example below is a JavaScript conceptual analogy, executed with Node.js. It uses Promise.all, AbortController and Promise.allSettled to illustrate cooperative cancellation and waiting for cleanup. It is not Python code and its tests do not execute or verify asyncio.gather(), TaskGroup, CancelledError or TimeoutError. The Python recommendations in this article come from the linked Python documentation, not from those JavaScript test results.

The fixture exports runNaive and runSafe. runNaive returns Promise.all without signalling its sibling workers after a failure. runSafe creates an AbortController, signals its cooperative workers, waits with Promise.allSettled, and then reports the original error. This demonstrates an application-owned JavaScript cleanup pattern, not a reimplementation of TaskGroup. The tests cover ordered successful results, continued sibling progress in the naive case, and stopped progress after the safe runner returns.

For a Python service, separately test a successful group, a child exception while another child is waiting, and an enclosing cancellation or timeout with the real I/O library. The JavaScript checks cannot establish Python exception-group behavior, CancelledError handling or TimeoutError conversion. Swallowing cancellation and deliberately shielding work also change the native lifetime; inspect those choices before relying on cleanup.

Working example

export class AbortError extends Error {
  constructor(message = 'Aborted') {
    super(message);
    this.name = 'AbortError';
  }
}

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(done, ms);

    function done() {
      cleanup();
      resolve();
    }

    function onAbort() {
      cleanup();
      reject(new AbortError());
    }

    function cleanup() {
      clearTimeout(timer);
      signal?.removeEventListener('abort', onAbort);
    }

    if (signal?.aborted) {
      cleanup();
      reject(new AbortError());
      return;
    }

    if (signal) signal.addEventListener('abort', onAbort, { once: true });
  });
}

export async function worker(id, {
  steps = 5,
  stepMs = 10,
  signal,
  failAtStep = null,
  progress = {},
} = {}) {
  for (let i = 1; i <= steps; i++) {
    if (signal?.aborted) throw new AbortError();
    await sleep(stepMs, signal);
    progress[id] = (progress[id] ?? 0) + 1;
    if (failAtStep === i) {
      throw new Error(`worker:${id} failed at step ${i}`);
    }
  }
  return `done:${id}`;
}

export async function runNaive(numWorkers, opts = {}) {
  const { failingWorker = null, progress = {}, steps = 5, stepMs = 10 } = opts;
  const tasks = Array.from({ length: numWorkers }, (_, id) => worker(id, {
    steps,
    stepMs,
    progress,
    failAtStep: id === failingWorker ? 2 : null,
  }));
  return Promise.all(tasks);
}

export async function runSafe(numWorkers, opts = {}) {
  const { failingWorker = null, progress = {}, steps = 5, stepMs = 10 } = opts;
  const controller = new AbortController();
  const tasks = Array.from({ length: numWorkers }, (_, id) => worker(id, {
    signal: controller.signal,
    steps,
    stepMs,
    progress,
    failAtStep: id === failingWorker ? 2 : null,
  }));

  try {
    return await Promise.all(tasks);
  } catch (error) {
    if (!(error instanceof AbortError)) controller.abort();
    await Promise.allSettled(tasks);
    throw error;
  }
}

Read the full article: https://chriseugenerodriguez.com/blog/asyncio-failing-task-does-not-leak-workers

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

Frameworks Are Institutional Memory

Ken W. Algerverified - Sep 17

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelski - Apr 23

Your Tech Stack Isn’t Your Ceiling. Your Story Is

Karol Modelski - Apr 9

My 30-day log cleanup kept a file that was 30 and a half days old

BashSnippets - Oct 8
chevron_left
575 Points • 24 Badges
Santa Monica, CA • chriseugenerodriguez.com
20Posts
0Comments
4Connections
10 years fe

Related Jobs

View all jobs →

Commenters (This Week)

5 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!