Skip to content

Exit quickly when Better Stack can't be reached, and deliver lines logged during shutdown - #58

Merged
PetrHeinz merged 10 commits into
mainfrom
claude/fast-quiet-shutdown
Oct 2, 2026
Merged

PetrHeinz merged 10 commits into
mainfrom
claude/fast-quiet-shutdown

Conversation

@PetrHeinz

@PetrHeinz PetrHeinz commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Every Logtail::Logger.new registered its own at_exit { close } (logger.rb:196), and HTTP#close waits up to 20 seconds (wait_on_request_queue) for an outlet thread that can't reach the ingesting host. With an unreachable host, exiting took 20 seconds per logger: rails runner, console, db:migrate and assets:precompile each gained about 20 seconds in the red-team, and three loggers on one device took 60. The hooks also kept every logger alive until exit. A line logged while Ruby shuts down, e.g. by a gem's background thread in its ensure block, made write start a thread, which Ruby refuses at that point: Ruby's Logger printed log writing failed. can't alloc thread and the line was lost. A line written after the device closed, e.g. by an at_exit hook registered before the logger, vanished without a word.

Reported in logtail/logtail-ruby-rails#42

  • Each HTTP device registers one at_exit hook that closes it, when the device is created. Logtail::Logger no longer registers one, and close does nothing the second time.
  • close stops waiting as soon as the outlet can't deliver: its thread is dead, or it has never had a response from the host and has backed off after a failed connection. A normal close still waits until everything is delivered.
  • A line written after close, or when Ruby refuses to start a thread, is delivered right away in the thread that writes it, with 2-second connect and read timeouts. Errors only go to the Logtail::Config debug log, never to stderr, also those outside StandardError (WebMock refuses connections with one); signals such as Interrupt are raised as usual. After one such delivery fails, later lines are dropped, so an unreachable host can't slow down the exit line by line.
  • While Ruby shuts down it also refuses the thread Net::HTTP needs (before Ruby 4.0) to time out connecting. The device then connects without that timeout, but only to a host that has answered this process before; otherwise the line is dropped quietly.

Behaviour and compatibility:

  • Loggers with other devices (STDOUT, files) are no longer closed at exit. Ruby's ::Logger::LogDevice writes synchronously, so nothing is lost, and STDOUT stays open for output from later at_exit hooks.
  • The at_exit hook is registered per HTTP device when it's created, so it runs after the hooks registered later, as before. It keeps the device referenced, not the loggers.
  • A refused connection or a failed DNS lookup is noticed within about a second. A host that drops packets still keeps close waiting until the outlet's first connection attempt times out (10 seconds) and backs off.
  • A line delivered right away counts any HTTP response as delivered, as the outlet does today.
  • A line written from a finalizer during shutdown can still be dropped, quietly.
  • The process tests start Ruby subprocesses with RbConfig.ruby -Ilib against a local TCP server on an ephemeral port.

Locally, with the red-team's manyloggers.rb (loggers sharing one device, refused port): main took 20.5 s to run and exit with one logger and 61.0 s with three, this branch 1.2 s with either, Ruby's start included. 100,000 Logtail::Logger.new on one device leave 1 logger alive and add 1 MB RSS, where the red-team measured 115 MB on main. A script that logs from an at_exit hook registered before the logger, from a background thread's ensure at exit and from a finalizer: on main, Ruby's Logger printed log writing failed. can't alloc thread twice and only the regular line arrived; here stderr stays empty and all four lines arrive, on Ruby 2.7, 3.0, 3.2, 3.3 and 3.4.

#57 (fork safety and Logger#flush) also changes http.rb. Both PRs add the same deliver_synchronously(requests) helper, SYNCHRONOUS_DELIVERY_TIMEOUT and spec/support/processes.rb, and otherwise touch different lines, so they merge cleanly in either order; a local merge of both branches passes the suite on Ruby 2.7, 3.0, 3.2 and 3.4. With both, close also delivers synchronously when the outlet thread is gone, and waits 5 seconds at most.

Follow-up after the E2E run of the release candidate (two more commits, the first one only a test, red on the Ruby 2.5 to 2.7 jobs): with warnings on (ruby -w), Ruby 2.7 and older warned that @closed, @last_resp and @late_delivery_failed were read before they were set: on every write, while close waits, and for a line written after close. They're set in initialize now. The warnings left with -w on Ruby 2.7 are the ones 0.1.20 prints too. #57 sets @last_resp on the same line, so the two still merge cleanly.

Targets the logtail 0.1.21 patch release. No dependencies on the other open PRs.

The first commit only adds the tests and is expected to fail on CI; the fix follows in the next commit. The last two add a failing test and the fix for an error outside StandardError escaping the delivery of a late line.

Follow-up (062d287 test, 6d27d8f change): synchronous deliveries now wait up to 5 seconds to connect and 5 seconds for the response (SYNCHRONOUS_DELIVERY_TIMEOUT, previously 2), Petr's choice. They cover lines logged after close, lines logged while Ruby refuses new threads at exit, and flush without an outlet thread. Normal delivery from the outlet thread keeps its 10 second connect and 30 second read timeouts. The new test logs two lines after close to a host that answers after 3 seconds; with 2 seconds the first delivery timed out and the second line was dropped.

Follow-up (fd9e3f2 test, b866aa7 fix): close waits until the request queue is empty and nothing is in flight. The outlet used to take a batch off the queue first and only then count it as in flight. If close checked in between, it saw neither, killed the outlet, and the batch was lost at exit. The same code is in 0.1.20. On MRI the window is tiny, but it made "closes a device once at exit" flaky on TruffleRuby, which runs threads in parallel (failed once on 6d27d8f, passed on re-run). The test widens the window: the line was lost 3 times out of 3 before the fix and delivered after it. The outlet now counts the batch as in flight before it takes it off the queue.

🤖 Generated with Claude Code

PetrHeinz and others added 2 commits October 1, 2026 19:00
Every Logtail::Logger registers its own at_exit hook that closes the
device, and close waits up to 20 seconds for an outlet that can't reach
the host, so an unreachable host makes exit take 20 seconds per logger.
A line logged while Ruby kills threads at exit prints "log writing
failed. can't alloc thread" and is lost, and a line written after the
device closed, e.g. by an earlier at_exit hook, vanishes.

The exit tests run logtail in a separate Ruby process against a local
ingesting server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each HTTP device registers one at_exit hook that closes it, instead of
  one hook per Logtail::Logger, and close does nothing the second time.
- close stops waiting for the outlet thread once it can't deliver: the
  thread is dead, or the host has never answered and the outlet has
  backed off after a failed connection. A refused port or a failed DNS
  lookup now costs about a second at exit instead of 20 per logger.
- A line written after close, or when Ruby refuses to start a thread
  while it shuts down, is delivered right away in the writing thread,
  with 2-second timeouts and errors only in the debug log, so Ruby's
  Logger no longer prints "log writing failed. can't alloc thread".
  Once such a delivery fails, later lines are dropped.
- Ruby also refuses the thread Net::HTTP (before Ruby 4.0) needs to time
  out connecting then, so the device connects without that timeout, but
  only to a host that has answered before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz and others added 4 commits October 1, 2026 19:30
A line written after close is delivered synchronously, which only
rescued StandardError, so an error outside it, like the one WebMock
refuses connections with, escaped write. Through Ruby's Logger that
prints "log writing failed" to stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deliver_synchronously now rescues every Exception but signals and only
writes it to the debug log, so a late line can't print "log writing
failed" or raise into the app. Interrupt and other signals are raised as
before. The helper stays the same as in the fork-safety PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With warnings on, Ruby 2.7 and older warn about every instance variable
that is read before it's set. The device reads @closed on every write and
in close, @last_resp while close waits, and @late_delivery_failed for a
line written after close, before setting them. ruby -w on 2.7 printed these
warnings, which 0.1.20 doesn't. The test fails on the Ruby 2.5 to 2.7 jobs
only, newer Rubies don't warn about this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@closed, @late_delivery_failed and @last_resp are set in initialize, so
Ruby 2.7 and older no longer warn that they are read before they're set.
The warnings left with -w on Ruby 2.7 are the ones 0.1.20 prints too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz added a commit that referenced this pull request Oct 2, 2026
So Ruby 2.7 and older no longer warn that deliver_synchronously reads it
before it's set. #58 sets it on the same line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PetrHeinz
PetrHeinz marked this pull request as ready for review October 2, 2026 09:17
PetrHeinz and others added 4 commits October 2, 2026 11:17
…onds

Lines logged after close are delivered in the calling thread. With the
2 second timeout a host that takes 3 seconds to answer fails the first
delivery, and the next line is dropped. This fails until the timeout is
raised to 5 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 seconds left little room for a slow network. 5 seconds matches how long
flush waits for the outlet, and still keeps exits short when Better Stack
can't be reached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ueue

The outlet takes a batch off the request queue before it counts it as in
flight. When close checks in between, it sees an empty queue and nothing in
flight, stops waiting and kills the outlet, and the batch is lost. The test
widens that moment; it fails until the outlet counts the batch first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
close waits until the request queue is empty and nothing is in flight. The
outlet used to take a batch off the queue first and count it afterwards, so
close could see neither, kill the outlet and lose the batch at exit. Rare on
MRI, but it made the exit tests flaky on TruffleRuby, which runs threads in
parallel. It's the same in 0.1.20.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz added a commit that referenced this pull request Oct 2, 2026
2 seconds left little room for a slow network. 5 seconds matches how
long flush waits for the outlet thread. #58 changes the same line the
same way, so the two still merge cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PetrHeinz
PetrHeinz merged commit 59de212 into main Oct 2, 2026
13 checks passed
@PetrHeinz
PetrHeinz deleted the claude/fast-quiet-shutdown branch October 2, 2026 11:36
PetrHeinz added a commit that referenced this pull request Oct 2, 2026
With #58 merged, a device that was closed delivers each line on its own,
and a forked child keeps the parent's closed state. So when the parent
closes the device before forking, every line the child logs goes out as its
own request. The local ingest server in the specs now records how many
lines each request held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz added a commit that referenced this pull request Oct 2, 2026
Conflicts in lib/logtail/log_devices/http.rb, both sides kept:
- require block: #54's require "set" and this branch's require "time"
- constants after MAX_RECONNECT_WAIT: this branch's MAX_RETRY_AFTER,
  REPORTED_REJECTIONS and REPORTED_REJECTIONS_LOCK, then
  SYNCHRONOUS_DELIVERY_TIMEOUT from #57/#58

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PetrHeinz added a commit that referenced this pull request Oct 2, 2026
…ivery

With #57 and #58 merged, a delivery in the calling thread (flush without an
outlet thread, lines written after close or during shutdown) counts any
HTTP response as delivered. So a batch rejected with 401 or 403 isn't
reported, and lines written after close keep going out after a 429 or 5xx.
Three of the four new examples fail for that. The fourth, a batch answered
with 408, 429 or 5xx is neither retried nor reported, passes already and
keeps the fix from reporting those statuses as rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant