Skip to content

Add FileWriter, and let the Decoder write articles straight to it - #203

Merged
Safihre merged 13 commits into
sabnzbd:masterfrom
mnightingale:feature/filewriter
Aug 21, 2026
Merged

Add FileWriter, and let the Decoder write articles straight to it#203
Safihre merged 13 commits into
sabnzbd:masterfrom
mnightingale:feature/filewriter

Conversation

@mnightingale

Copy link
Copy Markdown
Contributor

Enables the matching SABnzbd change, which shortens the download path from Downloader (Py) > Unlocked SSL (C) > Decoder (C) > Article Cache (Py) > Assembler (Py) > Disk write (Py/C) to Downloader (Py) > Unlocked SSL (C) > Decoder (C) > Disk write (C).

FileWriter

writer = sabctools.FileWriter(path)
writer.preallocate(size)   # sets the length, marking the file sparse first
writer.write(data, offset) # absolute offset, short writes retried internally
writer.close()             # idempotent, waits for writes in flight
  • Plus closed, path, size and context-manager support
  • Positional writes on Windows via WriteFile with an OVERLAPPED offset, so callers no longer need a lock to stand in for the missing os.pwrite
  • Owns its descriptor, so nothing outside can close it mid-write. A stale descriptor does not error, it writes into whatever file has since taken that number
  • Writes take a shared lock and run concurrently; only close() is exclusive
  • Supersedes sabctools.sparse(), kept for existing callers

Decoder: pairing, and an optional sink

  • Decoder.expect(context, sink=None) records a sent request; context comes back as NNTPResponse.context. Callers no longer keep a queue alongside the decoder's - two queues that drift write one article's bytes into another article's file at a plausible offset, which only par2 would catch
  • With a sink, the body is written at the offset from the yEnc headers and response.data is None; bodies over the staging buffer are written in pieces
  • uu articles carry no offsets, so a sink is ignored and data is populated as usual
  • One staging buffer per Decoder, reused for every article, so streaming costs no per-article allocation. Sized to the caller's input buffer (256 KiB)
  • NNTPResponse.sink_failed reports a failed write and a discarded body. The response still completes and the connection stays usable - abandoning it mid-stream would leave the rest of the article to be parsed as the next one, costing the whole connection instead of one article
  • The input ring is now rewound only once its free tail runs down, not every call. No observable change; needed later for in-place decoding

Notes

I think I could remove the sparse methods, the same is accomplished using FileWriter.

Owns the descriptor rather than borrowing one, so nothing outside can
close it while a write is in flight and a stale descriptor cannot send
data into a file that has since claimed that number.

Writes take a shared lock and run concurrently; only close takes it
exclusively, so it drains rather than pulling the handle away. On
Windows this is the point: WriteFile with an OVERLAPPED offset is
positional even on a non-overlapped handle, which os.pwrite is not
available to provide there, so callers no longer need a lock of their
own. Windows also opens through CreateFileW, giving a real HANDLE for
both the writes and the sparse ioctl with no msvcrt round trip.

Short writes are retried internally. preallocate() matches the existing
sparse() behaviour so the two can be swapped without changing what
callers see. sparse() itself is unchanged.
Decoder.expect(context, sink) records a request that has gone out.
NNTPResponse.context hands the object back, so the pairing lives here
rather than in a queue on the Python side kept in step with this one.
Two queues can drift, and a drift writes one article's bytes into
another article's file at a plausible-looking offset: silent corruption
that only par2 would catch.

With a sink, the body is decoded through a buffer shared by every
response on the connection and written at the offset the yEnc headers
declare, instead of building a bytearray per article. response.data is
left as None. Bodies larger than the staging buffer are flushed in
pieces, and the CRC is folded across them. uu carries no offsets, so a
sink is ignored there and data is populated as before. Callers that
never use expect() are unaffected.

Holding arbitrary Python objects makes cycles through these types
reachable - d.expect(d) is enough - so both now support GC, which they
did not before.

Measured over 60 articles of 700 KB held at once: peak RSS grows 44.4 MB
on the bytearray path against 0.2 MB streaming, with the payload on disk
instead.
expected reported only requests with nothing received yet, but the
pending entry is taken as soon as the first byte of a response arrives.
A pipelined connection therefore looked idle while it was still
receiving, and SABnzbd stopped reading from the socket: with two
requests outstanding only the first ever completed, and the job stalled.

It now counts responses finished but not yet collected, the one
arriving, and those still untouched. pending reports the same three,
oldest first, so the article a connection is fetching is the one being
received rather than the next one queued behind it.
Deleting a queue item while it was downloading closed the FileWriter
under an article still arriving, and the write error surfaced as
ValueError out of Decoder.process(). That killed the receive thread
that was running it.

The thread was the visible half. The real problem is that raising there
abandons the decoder in the middle of a response: the remainder of the
article is still in the connection's buffer and would be parsed as the
start of the next one. So the failure would cost the whole connection
rather than one article, and no amount of catching it in Python helps,
because by the time the exception surfaces the stream is already out of
step.

A sink write failure is fatal to the article and not to the connection.
The response is now consumed to its end with the body discarded, and
the failure is reported as NNTPResponse.sink_failed once it completes.
The article is refetched; anything already written is overwritten at
the same offsets, so a retry is idempotent.

The test that asserted the old behaviour is replaced by one that
decodes a second article on the same connection after a failure, which
is what a mid-response abort destroys and what a test for the raise
could never have caught.

Disk-full took the same path and killed a receive thread the same way.
It was 1 MiB, chosen so a typical ~700 KB article staged whole and was
written with one syscall. That reasoning does not survive measurement.

Decode CPU is flat from 256 KiB upward across 64 KiB to 4 MiB: the yEnc
decode dominates and the saved write syscalls do not register, with
1 MiB measuring no better than 256 KiB. Below 256 KiB they do start to
tell, 64 KiB costing about 9% more. So the extra 768 KiB bought
nothing, and it is paid once per connection - 200 connections is not
unusual, and 200 MB of staging eats the memory saving that streaming
exists to deliver. At 256 KiB the same case is 50 MB.

256 KiB is the caller's input buffer size, which is the number that
actually matters: one process() call cannot produce more decoded bytes
than its input holds, so this absorbs the most any one call can
generate and flushes land on call boundaries.

The comment claiming the buffer stays resident in L2/L3 is removed
rather than reworded. At any realistic connection count it does not,
and a buffer this size overflows L1 several times over on every
article.

The size is now overridable at build time, which is how the above was
measured.
Decoder_process reset the ring at the end of every call. The caller
writes its next read into [position, size) and that tail is what the
buffer protocol exports, so the reset buys nothing until the tail has
run down far enough to make the next read small.

The memmove is the eye-catching half of that and the irrelevant one. On
a yEnc stream the unprocessed remainder is almost always empty, because
the decoder carries partial lines in its own state rather than leaving
bytes in the buffer, so the branch that actually fired was the free
position = consumed = 0 reset. Measured over a realistic article
stream, the memmove ran zero times - deferring only the move was a
no-op, and the reset wipes the consumed region just as thoroughly. Both
are now deferred behind the same threshold.

Nothing above the decoder can see this: same decoded bytes, verified by
hashing the output of both builds over an article stream at several
read sizes, and a live download byte-identical to its source.

The point is what it makes possible rather than what it saves. It saves
nothing measurable - the eager path was a pointer reset. It leaves the
consumed region in front of the read pointer intact between calls,
which is where decoded output would live if the decoder ever writes in
place instead of into a separate staging buffer. With 48 KiB reads into
a 256 KiB ring that region now reaches ~192 KiB before a rewind.

The test fails against an always-rewind build, and also pins that the
tail never reaches zero: handing the caller an empty buffer would stall
the connection.
@Safihre

Safihre commented Aug 19, 2026

Copy link
Copy Markdown
Member

@claude review, I'm a C-noob, so help me determine if it's good code, structurally and functionally. It will be used together with sabnzbd/sabnzbd#3562, take that into account if possible

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Claude finished @Safihre's task in 4m 18s —— View job


Code review — FileWriter + Decoder sink/pairing

  • Gather context (diff, repo structure)
  • Read src/filewriter.h / src/filewriter.cc
  • Read src/yenc.h / src/yenc.cc (pairing + sink + GC + ring rewind)
  • Read src/sabctools.cc, src/unlocked_ssl.*, src/sparse.h
  • Read tests (test_filewriter.py, test_decoder_sink.py, test_decoder.py)
  • Cross-check against companion SABnzbd PR — not reachable from this sandbox (no outbound network access), reasoned instead from this PR's own description of the intended FileWriter/Decoder.expect(sink=...) usage pattern
  • Post review

Overall this is solid, careful C/C++ — good structure, and the concurrency story is well thought through and (mostly) consistently applied. One real bug worth fixing, plus a couple of nits.

Findings

1. Data race on FileWriter's read-only accessorssrc/filewriter.cc:366-401
FileWriter_get_size, FileWriter_get_closed, FileWriter_get_path, and FileWriter_repr all read self->handle directly with no lock. Every other place that touches handle (filewriter_write_raw, FileWriter_preallocate, FileWriter_close) takes self->lock — shared for reads/writes, exclusive for close — specifically so close() can't pull the handle out from under an in-flight operation. FileWriter_close releases the GIL and mutates handle under the exclusive lock, so a second thread calling .closed, .size, .path, or repr() while close() is running reads handle with no synchronization at all — a genuine data race by the C++ memory model (benign on most real platforms/compilers since it's a pointer/int-sized store, but still worth closing since the whole point of this design is to make concurrent access safe rather than "safe in practice"). .size additionally calls fstat/GetFileSizeEx on a handle that could be closed mid-call from another thread.

Fix: wrap the handle read in each of these four functions with std::shared_lock<std::shared_mutex> guard(self->lock);, same as filewriter_write_raw.

Fix this →

2. Minor: duplicate field initsrc/yenc.cc around line 1226 and 1250 in NNTPResponse_new
instance->sink_failed = false; is set twice (once with the other new sink fields, again a few lines later with the rest of the bools). Harmless, just redundant — worth a one-line cleanup.

3. Minor: possibly-redundant resetsrc/yenc.cc:1546 (self->staging_used = 0; in Decoder_decode when a new response starts)
By the time a new NNTPResponse is created, the previous one has always already hit its end-of-body flush (NNTPResponse_flush_sink is called whenever !instance->body), so staging_used should already be 0. Not a bug, just flagging in case it's silently papering over a path where the final flush could be skipped — if it's purely defensive, a short comment would help the next reader.

Structural notes (why the rest looks good)

  • FileWriter ownership model (src/filewriter.h:48-63) is the right call: owning the descriptor rather than borrowing one from Python closes off the "stale fd reused by another file" failure mode that a borrowed-fd design would have. shared_lock for concurrent writes/preallocate, unique_lock only for close(), is exactly the right RW split for "many writers, one closer, close waits for writers to drain."
  • Positional writes: pwrite() on POSIX and WriteFile + OVERLAPPED offset on Windows are both correctly used to avoid touching the shared file position, and short writes/EINTR are handled in a retry loop with a sane chunk cap (FILEWRITER_MAX_CHUNK, since WriteFile takes a DWORD and some POSIX implementations reject writes above SSIZE_MAX).
  • Decoder.expect() pairing replaces a parallel Python-side queue with one owned by the C++ decoder — this is a good design choice; a drifted second queue is exactly the kind of bug (wrong article written to a plausible-looking offset) that's very hard to catch, since only par2 would notice.
  • sink_failed semantics: on a write failure the article is drained and discarded rather than raising mid-stream, so the connection's byte-stream sync isn't lost and only the one article needs re-fetching, not the whole connection. This is exercised well by test_decoder_sink.py::TestSinkErrors (closed-before-transfer, closed-partway, and "does the next article on the same connection still decode correctly" — the case that actually validates the design goal).
  • GC support added correctly to both NNTPResponse and Decoder (Py_TPFLAGS_HAVE_GC + traverse/clear/GC_UnTrack in dealloc) now that they hold arbitrary Python objects (context, sink) that a caller could use to build a reference cycle. PyType_GenericAlloc auto-tracks on tp_new, so nothing's missing there.
  • Staging buffer sizing (YENC_STAGING_SIZE, src/yenc.h:74-101) is well justified in the comment — sized to the caller's input buffer rather than to an article, with the reasoning (measured CPU flat above 256 KiB, per-connection multiplier on memory) spelled out rather than asserted.
  • Ring buffer rewind deferral (Decoder_process, src/yenc.cc:1591-1644) checks out: the exported buffer is always [position, size) (Decoder_getbuffer), and the new threshold check (size - position < YENC_COMPACT_THRESHOLD) correctly matches that. Well covered by the new TestRingRewind tests, including one that explicitly checks decoded output is unchanged across read sizes.
  • Windows macro guards (WIN32_LEAN_AND_MEAN/NOMINMAX) and the reinterpret_cast<decltype(...)> fixes in unlocked_ssl.cc look like plumbing needed to compile filewriter.cc alongside the rest cleanly — reasonable, not scope creep.

Fit with the SABnzbd side (from the description in this PR + README.md)

The exposed contract — FileWriter(path)preallocate(size) once → concurrent write(data, offset) calls → close() — matches a Downloader → Decoder → disk pipeline with no assembler stage well: preallocate is safe to call once before any writes start (it shares the lock with writes rather than excluding them, so callers must sequence it first, which the tests model via Assembler.open-style preallocate-then-write). Decoder.expect(context, sink) returning context verbatim and pending/expected exposing the in-flight queue gives SABnzbd everything needed to route a response back to its Article and to requeue outstanding articles on a connection reset, without keeping a second queue in Python — directly addressing the drift failure mode called out in the PR description. I could not fetch sabnzbd/sabnzbd#3562 from this sandbox to confirm the calling code matches this exactly, so it's worth double-checking there that offsets passed to write()/preallocate() and the preallocate-before-write ordering hold.

The sparse() deprecation note in the PR body is reasonable — FileWriter.preallocate matches sparse()'s Windows behavior deliberately (leaves length alone silently if the sparse ioctl fails), so it's a safe drop-in once callers migrate.

@Safihre

Safihre commented Aug 19, 2026

Copy link
Copy Markdown
Member

How will it deal with disk full? I saw it just drops the data, but how would we report in SABnzbd?

And can we not detect if SSD using something like kipodd/ssd_checker#1?

close() mutates it under the exclusive lock with the GIL released, so the
accessors were reading it unsynchronised; size could also stat a descriptor that
close() had already released.
@mnightingale

mnightingale commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Data race on FileWriter's read-only accessors

Fixed in get_size, get_closed, and repr. get_path left as is because it reads path not the handle, it's set once in __init__ and only removed in dealloc

Minor: duplicate field init

Fixed

Minor: possibly-redundant reset

It's not redundant and Claude has commented why to help in the future

Disk full

That was rather missing, fetched articles into a black hole and failed the job.
Now raises an OSError after response is created, note not mid-stream because it needs to leave the connection usable.
Handled like the assembler does, with some improvements.

And can we not detect if SSD using something like kipodd/ssd_checker#1?

System probing will come after this PR; it had already gotten large.
But I already have commits working on this problem.

Briefly (he says before writing an essay) /sys/block/<dev>/queue/rotational won't work because it's not available in containers, it is often inaccurate; USB enclosures, VM disk, hardware RAID, ZFS, etc. I tried an SSD in an USB enclosure; there was no way to identify it as an SSD - support will vary

Also "is it an SSD" is not sufficient, I need to know can it sustain 700KB random writes at download rate which is not just down to the SSD; CPUfor processing, encryption, filesystem, network mount, RAID, etc.

A single probe at startup is also not sufficient, you end up just measuring the device's cache unless doing a long test.
So, what I need to do is measure how long writes take while downloading, and backoff or disable direct_decode when they get slow.

@Safihre

Safihre commented Aug 19, 2026

Copy link
Copy Markdown
Member

Just wondering if there's some way that we don't need the path back to Python anymore.
I know we need it because of buffered writing in case of hdd.

@mnightingale

mnightingale commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Just wondering if there's some way that we don't need the path back to Python anymore.
I know we need it because of buffered writing in case of hdd.

tl;dr; maybe and I've had in mind that it's something we might want to do in the future

In the current implementation, articles that return to Python with data are the ones queued without a sink: the first article, and any other queued whose filename is unresolved at that time (par2, md5of16k, or not yet seen yenc headers).

What we could do, and this abstraction makes much simpler is pass the destination directory in or temporary name, always write to file and rename if we need to once filenames are resoled. Unix allows renaming an open file descriptor, and so does Windows when the file is opened with specific flags - FileWriter already does so. I'm uncertain how that behaves across exotic filesystems but as long as the path degrades into the existing Python path it does not matter much.

If we do not take that path, then yes, ArticleCache and eventually Assembler could move to C - but as it stands I don't think it's worth it. The purpose of these changes are to spend less time in the cache or assembler, and on this streamed/direct_decode path the assembler does almost nothing; it fires on the first article and file_done, but usually it has nothing to do except set permissions and some file_done actions (which I may take off the assembler thread) - I measured CPU in the assembler went from 16.3s to 0.1 so there is nothing performance wise to gain and it just makes it more complicated.

Keeping the HDD path in C is complicated. You'd still need a cache, simple but you lose a lot of the visibility you get in Python which could lead to bugs like NzbFile removed but articles remain in memory. The core ordered writing of the assembler is only ~80 lines.

There is still room to improve the HDD contiguous part, its problem is the gap between download speed and write speed. The cache fills and there is no way back. Which is what this eliminates, writes happen inline with downloading while the GIL is released. A slow disk blocks the receive thread rather than backing up into memory so you cannot download faster than you can write, including while par2, unrar, etc are competing for the same disk.

The direct_decode path is 256KB of input plus 256KB of staging per connection, both C-owned and reused for every article passing through, so memory use is very predictable and allocation-free per article.

@Safihre

Safihre commented Aug 19, 2026

Copy link
Copy Markdown
Member

Good considerations, I agree.

@Safihre
Safihre merged commit e8646e6 into sabnzbd:master Aug 21, 2026
25 checks passed
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.

2 participants