Skip to content

fix: hold references to the persistence tasks - #3007

Open
noron12234 wants to merge 2 commits into
Chainlit:mainfrom
noron12234:fix/persistence-task-refs
Open

fix: hold references to the persistence tasks#3007
noron12234 wants to merge 2 commits into
Chainlit:mainfrom
noron12234:fix/persistence-task-refs

Conversation

@noron12234

@noron12234 noron12234 commented Jul 31, 2026

Copy link
Copy Markdown

The problem

Every write to the data layer is fired and forgotten:

if data_layer := get_data_layer():
    try:
        asyncio.create_task(data_layer.create_step(step_dict))
        self.persisted = True
    except Exception as e:
        ...

The task is discarded, and the event loop only keeps a weak reference:

Important: Save a reference to the result of this function, to avoid a task disappearing mid-execution. The event loop only keeps weak references to tasks. A task that isn't referenced elsewhere may get garbage collected at any time, even before it's done.

For logging or telemetry that would cost a datapoint. Here it costs user data: a collected task means a message, step or element is never written. There is no exception and nothing in the logs — the write simply never happens. self.persisted = True is set regardless, so the object believes it was saved. The failure only surfaces later as a thread with holes in it.

Note the try/except does not help: create_task schedules and returns immediately, so an error inside the coroutine never reaches that handler either.

Sites

Eight, across four modules — every place a data_layer.* write is spawned:

file call
context.py:98 update_thread during http context init
element.py:215 create_element
message.py:116 update_step
message.py:135 delete_step
message.py:150 create_step
step.py:345 update_step
step.py:370 delete_step
step.py:396 create_step

The change

Each module keeps a set and discards the task in a done callback:

_persistence_tasks: set[asyncio.Task] = set()
...
_task = asyncio.create_task(data_layer.create_step(step_dict))
_persistence_tasks.add(_task)
_task.add_done_callback(_persistence_tasks.discard)

Module-level rather than instance-level because Message, Step and Element are short-lived — an instance-scoped set would be collected along with the object it was meant to outlive. discard rather than remove so a double callback cannot raise. The set stays bounded by the number of in-flight writes.

No control flow, ordering or awaited behaviour changes. These writes were fire-and-forget before and remain so; they simply can no longer be collected mid-write.

Verification

$ ruff check chainlit/context.py chainlit/element.py chainlit/message.py chainlit/step.py
Found 2 errors.          # both pre-existing on main, unchanged by this PR
$ ruff format --check ...
4 files already formatted
$ python -m py_compile ...

I want to be straight about what I did not do: I did not run the test suite, so this is verified by lint and compile only. The change is mechanical — bind the task, register it, deregister on completion — but if you would like the suite run before merging, say so and I will.

No test is added either. The failure is a garbage-collection race, so a test would have to force a GC at a chosen moment and assert a task did not vanish, which is flaky by construction. Happy to add one if you have a shape in mind.

Found with an AST scan for create_task / ensure_future results discarded as bare expression statements (excluding TaskGroup.create_task, which does hold strong references). The scan reports other hits under llama_index/, openai/, mistralai/ and socket.py; those spawn UI-emit and stream work rather than data-layer writes, so I left them out to keep this diff to one concern. Happy to follow up.


Summary by cubic

Prevents lost data writes by holding strong references to all persistence tasks, including Step.__enter__/__exit__ dispatches. Every data-layer write is now tracked until completion so tasks can’t be garbage collected mid-execution.

  • Bug Fixes
    • Track in-flight persistence tasks in a module-level _persistence_tasks: set[asyncio.Task] in context.py, element.py, message.py, and step.py (including context-manager send()/update() calls).
    • Replace bare asyncio.create_task(...) with _task = asyncio.create_task(...); add to the set, then _task.add_done_callback(_persistence_tasks.discard).
    • No behavior change to flow or ordering; writes remain fire-and-forget but are no longer dropped.

Written for commit cbfcbd1. Summary will update on new commits.

Review in cubic

Every write to the data layer is fired with create_task() and the task is
discarded. The event loop only keeps a weak reference, so a task whose sole
reference was the create_task() expression can be garbage collected before it
reaches the data layer.

Unlike a dropped log line, this loses user data: a collected task means a
message, step or element is silently never persisted. There is no exception
and no log — the write simply never happens, and the failure only shows up
later as a thread with missing history.

Eight sites across four modules:

- context.py    update_thread on http context init
- element.py    create_element
- message.py    update_step, delete_step, create_step
- step.py       update_step, delete_step, create_step

Each file keeps a module-level set and discards the task in a done callback.
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. backend Pertains to the Python backend. bug Something isn't working data layer Pertains to data layers. labels Jul 31, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Re-trigger cubic

noron12234 added a commit to noron12234/chainlit that referenced this pull request Aug 4, 2026
…umentation

asyncio only keeps a weak reference to a task created via create_task()
or ensure_future(); a discarded reference lets the task be garbage
collected before it completes. Chainlit#3007 fixed the persistence call sites
in context.py/element.py/message.py/step.py; this covers the remaining
sites the same scan turned up, none of which touch the data layer:

- emitter.py: the method-queue flush, the new-message persistence
  dispatch, the first-interaction thread-init dispatch, and the
  file-element send loop in process_message().
- socket.py: the idle-session clear timer, the on_audio_chunk
  callback dispatch, and the audio first-interaction thread-init
  dispatch.
- server.py: the action-callback first-interaction thread-init
  dispatch (the third of three call sites of the same
  emitter.init_thread(...) pattern, alongside emitter.py and
  socket.py above).
- llama_index/callbacks.py: LlamaIndexCallbackHandler's
  on_event_start/on_event_end handlers dispatch step.send()/
  step.update() from a sync LlamaIndex callback; held via a new
  per-instance set.
- mistralai/__init__.py, openai/__init__.py: the instrumentation
  on_new_generation callbacks dispatch step.send() the same way.

Each file gets its own held-task set, following the pattern Chainlit#3007
established in the files it touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Step.__enter__/__exit__ dispatch step.send()/step.update() via
create_task() the same way the already-fixed persistence call sites in
this file do, but these two were missed by the original scan. Reuse the
module's existing _persistence_tasks set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@noron12234
noron12234 force-pushed the fix/persistence-task-refs branch from 2cc869e to cbfcbd1 Compare August 4, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Pertains to the Python backend. bug Something isn't working data layer Pertains to data layers. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant