Skip to content

Potential timeout-handle retention in PostgresMessageQueue.listen() under delayed retries #570

@dahlia

Description

@dahlia

Summary

While investigating gradual worker heap growth, I found a potential memory retention issue in PostgresMessageQueue.listen() under delayed retry traffic.

Why this may matter

If delayed messages are frequent, retained timeout handles can accumulate in memory and contribute to gradual heap growth in long-running workers.

Suspected code path

In packages/postgres/src/mq.ts, delayed notifications call timeouts.add(setTimeout(serializedPoll, durationMs)), but the handle is not removed from timeouts after the callback runs.

const timeouts = new Set<ReturnType<typeof setTimeout>>();

const listen = await this.#sql.listen(
  this.#channelName,
  async (delay) => {
    const durationMs = Temporal.Duration.from(delay).total("millisecond");
    if (durationMs < 1) await serializedPoll();
    else timeouts.add(setTimeout(serializedPoll, durationMs));
  },
  serializedPoll,
);

signal?.addEventListener("abort", () => {
  listen.unlisten();
  for (const timeout of timeouts) clearTimeout(timeout);
});

Expected behavior

Executed timeout handles should be removed from the tracking set so the set size does not grow monotonically during normal operation.

Suggested fix

Delete each handle when it fires and optionally clear the set during abort cleanup.

const timeout = setTimeout(() => {
  timeouts.delete(timeout);
  void serializedPoll();
}, durationMs);
timeouts.add(timeout);

Additional context

This was observed while investigating production-like worker memory growth in fedify-dev/hollo#365, so that issue is a related report rather than direct proof of root cause.

Metadata

Metadata

Assignees

Labels

Type

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions