Skip to content

fix(observability)!: write events inline so overlapping runs keep their events - #1741

Merged
Amir-R25 merged 10 commits into
mainfrom
1605-observer-concurrency
Oct 29, 2026
Merged

Amir-R25 merged 10 commits into
mainfrom
1605-observer-concurrency

Conversation

@Amir-R25

@Amir-R25 Amir-R25 commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1605.

When top-level runs overlap, from concurrent ainvoke calls on one loop or invoke calls in a thread pool, the first run to finish shut down the shared Observer. The other runs lost the rest of their events, and a run on another thread crashed in shutdown().

Changes

  • Writers are called synchronously on the thread that emits the event, with one lock per writer and per inline listener. The per-writer asyncio queues and consumer tasks are gone.
  • Runs no longer start or stop the Observer. Writers start on the first event and shut down once, at interpreter exit.
  • JsonlWriter closes a session's file when its session.completed is written.
  • configure_writers can be called at any time and shuts down the writers it replaces.

Breaking change

  • Custom writers must be synchronous: plain start(), write(event) and shutdown() methods. configure_writers raises a TypeError for a writer with async methods, and the "Customize event writers" docs now say so.
  • QueuePolicy, ensure_started and publish_event_nowait are removed, and publish_event and shutdown are no longer async.

Testing

  • New test_concurrent_runs.py covers both cases from the issue.
  • Unit and integration tests pass.

Follow-ups

Each of these will be a separate PR:

  • Reset writer locks in a forked child, in case another thread held one at the fork.
  • Add a reentrancy guard, so a writer or listener that emits an event can't deadlock on its own lock.
  • Rate-limit writer failure logs: the first failure with its traceback, then counts.
  • Keep an event's envelope when its payload can't be serialized, instead of losing the event.
  • Cap the number of open JSONL files, as a backstop for sessions that never complete.
  • Merge emit and emit_nowait into one synchronous emit. They're identical now that publishing is synchronous, so this removes the await from every emit call site.
  • Start writers as soon as configure_writers is called, and keep lazy creation only for the default JsonlWriter. This removes the Observer's "not started" state; decide there whether configure_writers([]) means no writers.

…r events

Every top-level run started and shut down the one process-wide Observer, so
the first run to finish stopped recording for every other run, and a run on
another thread crashed in shutdown() awaiting a task from a different loop.

- Writers are synchronous and called on the emitting thread, under a lock
  per writer and per inline listener; runs no longer start or stop the
  Observer. Writers start on the first event and shut down at exit.
- configure_writers can be called at any time; replaced writers shut down.
- JsonlWriter closes a session's file once session.completed is written.
- QueuePolicy, ensure_started and publish_event_nowait are removed;
  publish_event and the Writer protocol are synchronous.

Closes #1605
…ency

# Conflicts:
#	packages/railtracks/tests/unit_tests/observability/test_jsonl_writer.py
@Amir-R25
Amir-R25 marked this pull request as ready for review October 26, 2026 18:48
@Amir-R25
Amir-R25 requested a review from CoronRing October 26, 2026 18:48
@CoronRing

Copy link
Copy Markdown
Collaborator
  1. Async writers now fail silently. Writer, publish_event and shutdown changed from async to sync, and ensure_started were removed from robservability. Nothing seem to check for a writer that is still async. I passed one written for main's Writer protocol: the flow succeeded, 0 events were recorded, and the only sign was a RuntimeWarning: coroutine 'AsyncWriter.write' was never awaited.

claude said the fix is cheap:

Raise a TypeError in _start_writers or configure_writers when inspect.iscoroutinefunction(writer.write) (or start/shutdown) is true.
Mark the PR as a breaking change, with a short migration note in local_v2.md under "Customize event writers". That section mentions custom writers but never says they must be sync now, and its line "call configure_writers before the first flow invocation" can now say "any time".
2. A manual shutdown() maybe helpful to have a message saying it shut down for all. In doc or docstring,
3. is_observing calls _ensure_started() which write to my disk, I wonder if this is intended and covered by the no write policy

…ency

# Conflicts:
#	packages/railtracks/tests/unit_tests/observability/test_jsonl_writer.py
Writers now run inline on the publishing thread, so an async start, write or shutdown would never be awaited and its events would be dropped silently. configure_writers raises a TypeError naming the class and method instead, and leaves the current writers in place.
@Amir-R25 Amir-R25 changed the title fix(observability): write events inline so overlapping runs keep their events fix(observability)!: write events inline so overlapping runs keep their events Oct 29, 2026
@Amir-R25

Copy link
Copy Markdown
Collaborator Author

Thanks Guan, all three were good catches. Here's what changed:

  1. Async writers. configure_writers now raises a TypeError naming the class and method when a writer's start, write or shutdown is async, and the current writers stay in place (5cb864c). The PR title has the ! and the description has a "Breaking change" section. The "Customize event writers" section in local_v2.md now says writers must be plain sync methods and that configure_writers can be called at any time (7c239be). This is very unlikely to break anyone: the main consumer of these events is the v2 visualizer, which is still in beta and reads the JSONL files the default writer produces. Still, the check means a writer written the old way fails loudly instead of quietly recording nothing.

  2. Manual shutdown(). The docstrings on configure.shutdown() and Observer.shutdown() now say it stops recording for every run in the process, later events are ignored, it can't be restarted, and it already runs at exit, so there's normally no need to call it (65c053b).

  3. is_observing writing to disk. That's intended, and the no-write setting covers it. With RAILTRACKS_DISABLE_EVENTS set, the default writer list is empty, so nothing starts and nothing is written. Without it, starting the default JsonlWriter only creates the events directory and its .gitignore. Main does the same at the start of every run through ensure_started(), and no event file is created until an event is written. is_observing has to start the writers to give the right answer: its only caller decides whether to record rt.context events, and returning False before the first event would drop them. In practice it's called inside a run, after the session events have already started the writers. I added a docstring line saying it starts them (90f7e08), and the last follow-up in the description (starting writers in configure_writers) removes the side effect.

@CoronRing CoronRing left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@Amir-R25

I love the changes! Thanks for the quick turnaround, this is good to go. All three points are handled, and the async writer check is exactly what I was hoping for: an old-style writer now fails loudly at configure_writers with the class and method in the message, and the writers already in place stay put instead of being half-replaced.

The shutdown() docstrings now make it clear that it stops recording for the whole process and can't be restarted, which is the part a user would trip on. Your reasoning on is_observing makes sense to me too: it only does what ensure_started() already did at the start of every run on main, RAILTRACKS_DISABLE_EVENTS still turns it off, and the follow-up that starts writers in configure_writers removes the side effect for good.

The new "Customize event writers" paragraph reads well. It says the writers must be sync, run on the emitting thread one call at a time, and should keep write quick, which is all someone writing a custom writer needs to know.

One small thing, fine as part of the follow-ups: publish loops over a snapshot of self._writers, so if configure_writers swaps writers mid-publish, that event still goes to the old writer after it has been shut down. For JsonlWriter this reopens the scope's file in append mode and leaves the handle open. A closed flag on _Sink that _shutdown_writer sets and __call__ checks under the lock would close the gap.

The branch is a couple of commits behind main (both unrelated), so just update it before merging. I'll close #1683 once this lands.

Ship it! ٩(◕‿◕)۶

@Amir-R25
Amir-R25 merged commit b699fe9 into main Oct 29, 2026
21 checks passed
@Amir-R25
Amir-R25 deleted the 1605-observer-concurrency branch October 29, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent top-level flows share one Observer, and the first to finish shuts it down (lost events, cross-thread crash)

2 participants