Repository navigation
feat: batch PrintBuffer fd flushes with a background sweeper - #1191
ZaneHyattAB wants to merge 6 commits into
Conversation
…ch the platform promptly Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…n every log line Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou can test this version of the CDK using the following: # Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@devin/1790713331-print-buffer-sweeper#egg=airbyte-python-cdk[dev]' --help
# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch devin/1790713331-print-buffer-sweeperPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
PyTest Results (Fast)4 988 tests +6 4 976 ✅ +6 11m 57s ⏱️ + 2m 24s Results for commit ad5e586. ± Comparison against base commit d214b0c. This pull request removes 1 and adds 7 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
PyTest Results (Full)4 991 tests +6 4 979 ✅ +6 9m 48s ⏱️ - 6m 16s Results for commit ad5e586. ± Comparison against base commit d214b0c. This pull request removes 1 and adds 7 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
#1167 landed on main as a squash of the commit this branch already carries, so both files keep this branch's version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweeper only caught OSError/ValueError. Any other exception killed the thread while _sweeper still pointed at it, so later writes marked output pending for a dead thread and it sat in the stdout buffer again, silently. Treat any exception like a broken pipe: switch to synchronous fd flushes and exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
#1167 made
PrintBuffer.flush()also callsys.__stdout__.flush(), so output reaches a real pipe promptly. Butlogging.StreamHandler.emitcallsflush()after every record, so every log line became onewrite(2): 23x the syscalls and about 10% lower throughput at 1 log line per record. This PR keeps #1167's delivery guarantee and batches the syscalls again. The fd flush moves off the hot path and into a lazy background sweeper.Lifecycle
write/flush.__enter__alone does not start it. Idle buffers never start a thread.__exit__: stop and join the sweeper (never while holdingself.lock), then flush everything pending to the fd synchronously, then restoresys.stdout/sys.stderr. A later write starts a fresh sweeper, so re-entering the context works.__exit__: the first sweeper start also registers a module-levelatexithook (_shutdown_all). It switches each registered buffer to_sync_flush, stops and joins its sweeper, then does a final fd flush.atexitruns before the interpreter tears down daemon threads, so pending output is never left to a killed daemon thread. Buffers are tracked in aWeakSet.Thread.start()raisesRuntimeError(for example at interpreter shutdown or a thread limit), the buffer switches to_sync_flush, which gives fix: flushsys.__stdout__inPrintBuffer.flush()#1167's behaviour. If the sweeper's own fd flush raises anything (a broken pipe or closed stdout, or an unexpected error), the sweeper sets_sync_flushand exits, so output never waits on a dead sweeper and the next caller-threadflush()raises the error as it did before.Fork safety:
os.register_at_fork(skipped where unavailable, e.g. Windows):before: acquire every registered buffer's lock and flush pending bytes to the fd, so the child cannot re-emit them.after_in_parent: release the locks. The parent's sweeper keeps running.after_in_child: replacelock/_stop/_pending, and set_sweeper = Noneand_pending_marked = False. The child starts its own sweeper on its next write instead of believing a dead thread is running.Unchanged for non-
PrintBufferusers: nothing happens at import time. No hooks are registered and no thread is started until aPrintBufferis written to.Measurements
Method from the #1167 review thread: real child-process stdout pipe, real
airbyte_cdklogger viaPRINT_BUFFERandentrypoint-styleprint,strace -f -c -e trace=write, 3 runs each, Python 3.12.write()write()Quiet-line latency is the time from logging one line and then going idle (the child sleeps 3 s before exiting) until the parent reads it from the pipe. On
main, the line only arrives when the process exits.straceadds a lot of noise at 1:1000. An interleaved run withoutstrace(5 runs each, 1M records, 1:1000, sorted) shows no measurable difference: before #1167 611,586 to 633,211; #1167 507,014 to 621,803; this PR 598,663 to 631,848 rec/s.Tests (
unit_tests/utils/test_print_buffer.py)sys.__stdout__inPrintBuffer.flush()#1167'stest_output_reaches_pipe_before_process_exitis unchanged and passes.flush()writes to stdout without callingstdout.flush(), and the sweeper calls it later. This is event-based, not sleep-based.__exit__flushes everything pending and the sweeper thread is dead afterwards.airbytelogger and a counting raw stream. Raw writes must stay at or belowbytes // 8192 + 1 + elapsed / interval + 3. fix: flushsys.__stdout__inPrintBuffer.flush()#1167's implementation fails this, which was checked by restoring it.2 * flush_interval, and the child is still alive when it arrives.with PRINT_BUFFER:reach the pipe exactly once on__exit__. A log line and a rawwrite()made after the context has exited (with no later flush) are delivered by theatexithook.os.fork()in a subprocess: parent and child each deliver post-fork lines through their own sweeper, and pre-fork output is not duplicated.selectwith generous deadlines and process-group cleanup. Nothing uses sleep for synchronization.poetry run pytest unit_tests/utils/test_print_buffer.py unit_tests/test_logger.py unit_tests/test_entrypoint.pypassed (66 passed, 1 skipped on macOS: the quiet-line latency test is Linux-only). The print-buffer file passed on repeated runs. ruff check, ruff format and mypy are clean.Where output can still be delayed or lost
sys.__stdout__userspace buffer for about oneflush_interval(100 ms) plus scheduling jitter before reaching the fd. A long-heldself.lockor a starved thread can stretch that.os._exit(),SIGKILL, a fatal signal, or a default-dispositionSIGTERMskipsatexit, so up to about one interval of output is lost. Before fix: flushsys.__stdout__inPrintBuffer.flush()#1167, up to 8 KiB plus theStringIOcontents was lost in the same cases. fix: flushsys.__stdout__inPrintBuffer.flush()#1167 loses only theStringIOcontents.flush(), but the bytes are gone either way._sync_flushfallback cases (thread start failure, afteratexit, after a sweeper error) behave like fix: flushsys.__stdout__inPrintBuffer.flush()#1167: one fd flush perflush().Link to Devin session: https://app.devin.ai/sessions/e843f60236164bd4bd8b56d6ee7c009b
Open in Devin Desktop: https://app.devin.ai/desktop/session/e843f60236164bd4bd8b56d6ee7c009b?variant=devin
Requested by: ZaneHyattAB