PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
scratch_file.hpp
Go to the documentation of this file.
1#pragma once
2
6
7#include <algorithm>
8#include <cerrno>
9#include <cstddef>
10#include <cstdint>
11#include <cstdlib>
12#include <fcntl.h>
13#include <limits>
14#include <memory>
15#include <stdexcept>
16#include <string>
17#include <sys/stat.h>
18#include <system_error>
19#include <unistd.h>
20#include <utility>
21#if defined(__linux__)
22#include <sys/vfs.h>
23#endif
24
25namespace pineforge::detail {
26
27// Compatibility name for ledger/phase users of the report-scratch backend.
29
30// Run-owned positional scratch: an anonymous, close-on-exec file for an empty
31// owner, or checked slab copies when bound. Without a slab, open() selects an
32// explicitly attached arena, or the original mkstemp/unlink/pwrite/pread backend.
33// Sparse growth reads as zero in both modes, including reused arena chunks;
34// reads beyond the logical end still fail with EIO. No implicit attachment
35// or RAM fallback. clear() releases the region and its mapped windows.
37public:
38 // Fixed byte window shared by scratch I/O amortisation contracts.
39 static constexpr std::size_t kCacheBytes = 64 * 1024;
40
42 explicit ScratchFile(IoRaiser raiser = IoRaiser::report_sink) : io_raiser_(raiser) {}
43 ScratchFile(const ScratchFile&) = delete;
45 ScratchFile(ScratchFile&& other) noexcept { *this = std::move(other); }
46 ScratchFile& operator=(ScratchFile&& other) noexcept {
47 if (this != &other) {
48 clear();
49 fd_ = std::exchange(other.fd_, -1);
50 directory_ = std::move(other.directory_);
51 slab_ = std::move(other.slab_);
52 resource_ = other.resource_;
53 arena_ = std::exchange(other.arena_, nullptr);
54 region_ = std::move(other.region_);
55 other.region_.size = 0;
56 io_raiser_ = other.io_raiser_;
57 bounded_cache_ = other.bounded_cache_;
58 clean_bytes_read_ = std::exchange(other.clean_bytes_read_, 0);
59 bytes_written_since_sync_ = std::exchange(other.bytes_written_since_sync_, 0);
60 }
61 return *this;
62 }
64
65 // Binding is a begin/reset operation. Keep shared ownership, never a byte
66 // view: every access below goes through the slab's checked copy API.
67 void bind_scratch(const std::shared_ptr<ScratchSlab>& slab, ScratchResource resource) {
68 if (slab) (void)slab->region(resource);
69 clear();
70 slab_ = slab;
71 resource_ = resource;
72 if (slab_) slab_->reset(resource_);
73 }
74 const std::shared_ptr<ScratchSlab>& scratch_owner() const noexcept { return slab_; }
75 ScratchResource scratch_resource() const noexcept { return resource_; }
76 // Optional diagnostic identity; set before open(), including lazy opens.
77 // Default unknown, retained by clear/reopen and moves; file mode ignores
78 // it. ScratchFile remains noncopyable; owners cloning a store reapply it.
79 // CR150-CX's ports label cohort (cohorts_by_id_), exit_rank
80 // (exit_rank_keys_), erased_minima (IdPairStore data) and its *_idx
81 // file, partial_cycles (consumed_partial_exit_cycles_ data) and
82 // partial_cycles_idx (hash/fold/journal), and successor_roots.
83 // l22p labels curve (equity scratch shared by SpillCurve/read windows)
84 // and position_tail (L18's separate spill). Rehash replacement files
85 // inherit their index label before open(); prefixes are never identities.
86 void set_store(ScratchStore store) noexcept { region_.store = store; }
87
88 // Opt in before opening. Only the ledger/phase consumers request this;
89 // existing scratch users keep their established I/O behavior.
90 void bound_cache() {
91 if (fd_ >= 0) throw std::logic_error("scratch cache policy changed after open");
92 bounded_cache_ = true;
93 }
94 void set_directory(const std::string& value) {
95 if (arena_) return;
96 if (fd_ >= 0 && directory_ != value)
97 throw std::logic_error("scratch directory changed after spill");
98 directory_ = value;
99 }
100 void clear() noexcept {
101 if (arena_) arena_->release_region(region_);
102 arena_ = nullptr;
103 if (fd_ >= 0) ::close(fd_);
104 fd_ = -1;
105 clean_bytes_read_ = bytes_written_since_sync_ = 0;
106 // resource_ was validated at bind. Clearing reuses the same allocation
107 // and binding; a later empty-owner bind explicitly restores disk mode.
108 if (slab_) slab_->reset(resource_);
109 }
110 bool opened() const noexcept { return fd_ >= 0 || arena_ != nullptr; }
111 bool arena_backed() const noexcept { return arena_ != nullptr; }
112 // The report facade retains its file-mode descriptor/truncate API.
113 // Arena readers continue to borrow the store rather than the shared fd.
114 int descriptor() const noexcept { return fd_; }
115 void truncate(std::uint64_t bytes) {
116 if (slab_) throw std::logic_error("scratch truncate requires a file or arena");
117 check_extent(bytes, 0, arena_ != nullptr, io_raiser_);
118 if (arena_) {
119 arena_io([&] { arena_->truncate(region_, bytes); });
120 return;
121 }
122 int result;
123 do { result = ::ftruncate(fd_, static_cast<off_t>(bytes)); }
124 while (result != 0 && errno == EINTR);
125 if (result != 0) io_error(errno, "report scratch truncate");
126 }
127 void open(const char* prefix) {
128 if (slab_) return;
129 if (opened()) return;
130 if (auto* arena = ScratchArena::attached()) {
131 arena_io([&] { arena->open_region(region_); });
132 arena_ = arena;
133 return;
134 }
135 std::string refusal;
136 int directory_error = 0;
137 std::string name = resolve_scratch_directory(directory_, refusal, directory_error);
138 if (name.empty()) {
139 // Keep the resolver's errno and the established typed raiser for
140 // bounded and unbounded stores alike.
141 io_error(directory_error, refusal.c_str());
142 }
143 const auto create_message = scratch_create_message("scratch create", name, refusal);
144 if (name.back() != '/') name += '/';
145 name += prefix;
146 name += "-XXXXXX";
147 const int opened = ::mkstemp(name.data());
148 if (opened < 0) io_error(errno, create_message.c_str());
149 if (::unlink(name.c_str()) != 0) {
150 const int error = errno;
151 ::close(opened);
152 io_error(error, "scratch unlink");
153 }
154 if (::fcntl(opened, F_SETFD, FD_CLOEXEC) == -1) {
155 const int error = errno;
156 ::close(opened);
157 io_error(error, "scratch cloexec");
158 }
159 fd_ = opened;
160 if (bounded_cache_) {
161#if defined(__linux__)
162 struct statfs backing{};
163 if (::fstatfs(fd_, &backing) != 0) {
164 const int error = errno;
165 clear();
166 io_error(error, "scratch backing");
167 }
168 // These are memory filesystems, not an external row source.
169 if (scratch_filesystem_is_memory(backing.f_type)) {
170 clear();
171 io_error(ENODEV, "scratch requires disk backing");
172 }
173 const int error = ::posix_fadvise(fd_, 0, 0, POSIX_FADV_RANDOM);
174 if (error != 0) {
175 clear();
176 io_error(error, "scratch random access");
177 }
178#elif defined(__APPLE__)
179 if (::fcntl(fd_, F_NOCACHE, 1) == -1) {
180 const int error = errno;
181 clear();
182 io_error(error, "scratch uncached access");
183 }
184#endif
185 }
186 }
187 // Explicit arena-only path. Reserve the whole chunk directory now, before
188 // the first write; max_bytes may be zero (a sub-chunk arena share).
189 // Ordinary open() and all existing report regions keep their policy.
190 void open_bounded(const char* /*prefix*/, std::uint64_t max_bytes) {
191 if (slab_)
192 throw std::logic_error("bounded scratch requires an arena region");
193 if (opened()) {
194 if (!arena_ || region_.max_bytes != max_bytes)
195 throw std::logic_error("bounded scratch policy changed after open");
196 return;
197 }
198 auto* arena = ScratchArena::attached();
199 if (!arena) throw std::logic_error("bounded scratch requires an attached arena");
200 check_extent(max_bytes, 0, true, io_raiser_);
201 arena_io([&] { arena->open_region(region_, max_bytes); });
202 arena_ = arena;
203 }
204 // Monotone, zero-filled growth, for sparse hash indexes and similar
205 // ftruncate users. Does not shrink or open the file. Arena mode claims
206 // chunks without a sizing syscall; file mode uses fstat + ftruncate.
207 // A bounded caller can supply the arena size required for this extent.
208 // Only an exhaustion uses it; zero preserves report users' chunk demand.
209 void ensure_size(std::uint64_t bytes, std::uint64_t needed_bytes_lower_bound = 0) {
210 if (slab_) {
211 slab_->extend(resource_, bytes);
212 return;
213 }
214 check_extent(bytes, 0, arena_ != nullptr, io_raiser_);
215 if (!opened()) throw std::logic_error("scratch growth before open");
216 if (arena_) {
217 arena_io([&] { arena_->ensure_size(region_, bytes, needed_bytes_lower_bound); });
218 return;
219 }
220 struct stat status{};
221 int result;
222 do { result = ::fstat(fd_, &status); } while (result != 0 && errno == EINTR);
223 if (result != 0) io_error(errno, "scratch size");
224 if (status.st_size >= static_cast<off_t>(bytes)) return;
225 do { result = ::ftruncate(fd_, static_cast<off_t>(bytes)); }
226 while (result != 0 && errno == EINTR);
227 if (result != 0) io_error(errno, "scratch resize");
228 }
229 static void check_extent(std::uint64_t offset, std::size_t length, bool arena = false,
231 const auto maximum = static_cast<std::uint64_t>(std::numeric_limits<off_t>::max());
232 if (offset > maximum || length > maximum - offset) {
233 if (raiser == IoRaiser::auxiliary)
234 throw AuxiliaryScratchIoError(EFBIG, "scratch offset exhausted");
235 throw ReportScratchIoError(EFBIG, "scratch offset exhausted",
236 {{"reason", arena ? "arena_io" : "scratch_io"}});
237 }
238 }
239 // One positional transfer for adapters that already own a retry loop and
240 // observe actual short transfers (IdPairScratchFile's fixture counters).
241 // File mode returns the syscall result/errno, including EINTR; arena/slab
242 // mode copies the same prefix or throws its existing typed failure.
243 // No descriptor escape: even fixture prefix writes select the arena.
244 ssize_t write_some(std::uint64_t offset, const void* data, std::size_t length) {
245 check_extent(offset, length, arena_ != nullptr, io_raiser_);
246 // This window-edge cap is inert for current users: IdPairScratchFile
247 // does not enable bound_cache(), and cache-bound users call write().
248 const auto count = std::min(length, bounded_cache_
249 ? kCacheBytes - static_cast<std::size_t>(offset % kCacheBytes)
250 : static_cast<std::size_t>(std::numeric_limits<ssize_t>::max()));
251 if (slab_) slab_->write_at(resource_, offset, data, count);
252 else if (arena_) arena_io([&] { arena_->write(region_, offset, data, count); });
253 else {
254 prepare_write();
255 const auto chunk = std::min(count, bounded_cache_
256 ? kCacheBytes - bytes_written_since_sync_
257 : static_cast<std::size_t>(std::numeric_limits<ssize_t>::max()));
258 const ssize_t written = ::pwrite(fd_, data, chunk, static_cast<off_t>(offset));
259 if (written > 0 && bounded_cache_)
260 bytes_written_since_sync_ += static_cast<std::size_t>(written);
261 return written;
262 }
263 return static_cast<ssize_t>(count);
264 }
265 ssize_t read_some(std::uint64_t offset, void* data, std::size_t length) const {
266 check_extent(offset, length, arena_ != nullptr, io_raiser_);
267 // This window cap is likewise inert for current users: IdPairScratchFile
268 // leaves bound_cache() off, and cache-bound users call read().
269 const auto count = std::min(length, bounded_cache_ ? kCacheBytes
270 : static_cast<std::size_t>(std::numeric_limits<ssize_t>::max()));
271 if (slab_) slab_->read_at(resource_, offset, data, count);
272 else if (arena_) arena_io([&] { arena_->read(region_, offset, data, count); });
273 else {
274 // Retry a failed discard before admitting another read batch.
275 if (bounded_cache_ && clean_bytes_read_ == kCacheBytes) discard_clean_cache();
276 const auto chunk = std::min(count, bounded_cache_
277 ? kCacheBytes - clean_bytes_read_
278 : static_cast<std::size_t>(std::numeric_limits<ssize_t>::max()));
279 const auto received = ::pread(fd_, data, chunk, static_cast<off_t>(offset));
280 if (received > 0 && bounded_cache_) {
281 clean_bytes_read_ += static_cast<std::size_t>(received);
282 if (clean_bytes_read_ == kCacheBytes) discard_clean_cache();
283 }
284 return received;
285 }
286 return static_cast<ssize_t>(count);
287 }
288 void write(std::uint64_t offset, const void* data, std::size_t length) {
289 if (slab_) {
290 slab_->write_at(resource_, offset, data, length);
291 return;
292 }
293 check_extent(offset, length, arena_ != nullptr, io_raiser_);
294 if (arena_) {
295 arena_io([&] { arena_->write(region_, offset, data, length); });
296 return;
297 }
298 const auto* bytes = static_cast<const unsigned char*>(data);
299 std::size_t written = 0;
300 while (written < length) {
301 prepare_write();
302 const auto chunk = std::min(length - written, bounded_cache_
303 ? kCacheBytes - bytes_written_since_sync_
304 : static_cast<std::size_t>(std::numeric_limits<ssize_t>::max()));
305 const ssize_t count = ::pwrite(fd_, bytes + written, chunk,
306 static_cast<off_t>(offset + written));
307 if (count < 0 && errno == EINTR) continue;
308 if (count <= 0) io_error(count < 0 ? errno : EIO, "scratch write");
309 written += static_cast<std::size_t>(count);
310 if (bounded_cache_) bytes_written_since_sync_ += static_cast<std::size_t>(count);
311 }
312 }
313 void read(std::uint64_t offset, void* data, std::size_t length) const {
314 if (slab_) {
315 slab_->read_at(resource_, offset, data, length);
316 return;
317 }
318 check_extent(offset, length, arena_ != nullptr, io_raiser_);
319 if (arena_) {
320 arena_io([&] { arena_->read(region_, offset, data, length); });
321 return;
322 }
323 auto* bytes = static_cast<unsigned char*>(data);
324 std::size_t received = 0;
325 while (received < length) {
326 // Retry a failed discard before admitting another read batch.
327 if (bounded_cache_ && clean_bytes_read_ == kCacheBytes) discard_clean_cache();
328 const auto chunk = std::min(length - received, bounded_cache_
329 ? kCacheBytes - clean_bytes_read_
330 : static_cast<std::size_t>(std::numeric_limits<ssize_t>::max()));
331 const ssize_t count = ::pread(fd_, bytes + received, chunk,
332 static_cast<off_t>(offset + received));
333 if (count < 0 && errno == EINTR) continue;
334 if (count <= 0) io_error(count < 0 ? errno : EIO, "scratch read");
335 received += static_cast<std::size_t>(count);
336 if (bounded_cache_) {
337 clean_bytes_read_ += static_cast<std::size_t>(count);
338 if (clean_bytes_read_ == kCacheBytes) discard_clean_cache();
339 }
340 }
341 }
342
343private:
344 template<class Operation> void arena_io(Operation operation) const {
345 try { operation(); }
346 catch (const ReportScratchIoError& error) {
347 const auto& failure = error.run_failure();
348 // The arena's capacity/backing contract is unchanged. I/O raised
349 // for an auxiliary table retains that table's code and errno.
350 if (io_raiser_ == IoRaiser::auxiliary
351 && failure.code == RunFailureCode::report_sink_failed
352 && failure.args && *failure.args == "{\"reason\":\"arena_io\"}")
353 throw AuxiliaryScratchIoError(error);
354 throw;
355 }
356 }
357 [[noreturn]] void io_error(int error, const char* operation) const {
358 if (io_raiser_ == IoRaiser::auxiliary)
359 throw AuxiliaryScratchIoError(error, operation);
360 throw ReportScratchIoError(error, operation, {{"reason", "scratch_io"}});
361 }
362 // Bound traffic between whole-file discards: at most kCacheBytes of reads
363 // and kCacheBytes of unsynced writes per file, independent of offset span.
364 // With RANDOM disabling read-ahead, the current users on 4 KiB OS pages
365 // have much smaller per-window footprints: TradeLedger's 168 B records
366 // and short strings touch at most about 391 pages; ScratchSequence's
367 // whole 64 KiB windows touch at most about 17 pages. The universal
368 // worst-case bound across the read and unsynced-write windows is
369 // 2*kCacheBytes OS pages for one-byte accesses to distinct pages:
370 // 512 MiB at 4 KiB pages, not 128 KiB of physical cache. DONTNEED retains
371 // any still-dirty pages; the write counter bounds those until the next
372 // sync and discard.
373 void discard_clean_cache() const {
374 if (!bounded_cache_) return;
375#if defined(__linux__)
376 const int error = ::posix_fadvise(fd_, 0, 0, POSIX_FADV_DONTNEED);
377 if (error != 0) io_error(error, "scratch discard cache");
378#endif
379 clean_bytes_read_ = 0;
380 }
381 void prepare_write() {
382 if (!bounded_cache_ || bytes_written_since_sync_ < kCacheBytes) return;
383#if defined(__linux__)
384 int result;
385 do { result = ::fdatasync(fd_); } while (result != 0 && errno == EINTR);
386 if (result != 0) io_error(errno, "scratch flush cache window");
387#endif
388 // Only the successful byte count triggers sync; a far-back update
389 // followed by a tail append cannot flush once per offset change.
390 discard_clean_cache();
391 bytes_written_since_sync_ = 0;
392 }
393 int fd_ = -1;
394 // Group policy scalars in existing padding: the fixed fold-segment
395 // arrays embed this type and retain their unchanged 32 KiB assertion,
396 // including the command lane's additional per-region limit metadata.
397 IoRaiser io_raiser_ = IoRaiser::report_sink;
398 std::string directory_;
399 // Physical backing only; logical table records and hash folds are shared
400 // by both backends. No region base/payload pointer is retained here.
401 std::shared_ptr<ScratchSlab> slab_;
403 bool bounded_cache_ = false;
404 ScratchArena* arena_ = nullptr;
405 ScratchArena::Region region_;
406 mutable std::size_t clean_bytes_read_ = 0;
407 std::size_t bytes_written_since_sync_ = 0;
408};
409} // namespace pineforge::detail
static ScratchArena * attached() noexcept
ssize_t write_some(std::uint64_t offset, const void *data, std::size_t length)
ScratchFile(IoRaiser raiser=IoRaiser::report_sink)
ssize_t read_some(std::uint64_t offset, void *data, std::size_t length) const
void truncate(std::uint64_t bytes)
void bind_scratch(const std::shared_ptr< ScratchSlab > &slab, ScratchResource resource)
bool arena_backed() const noexcept
static void check_extent(std::uint64_t offset, std::size_t length, bool arena=false, IoRaiser raiser=IoRaiser::report_sink)
void ensure_size(std::uint64_t bytes, std::uint64_t needed_bytes_lower_bound=0)
static constexpr std::size_t kCacheBytes
void open(const char *prefix)
void set_store(ScratchStore store) noexcept
void write(std::uint64_t offset, const void *data, std::size_t length)
ScratchFile & operator=(const ScratchFile &)=delete
ScratchFile(ScratchFile &&other) noexcept
void set_directory(const std::string &value)
ScratchFile & operator=(ScratchFile &&other) noexcept
void open_bounded(const char *, std::uint64_t max_bytes)
bool opened() const noexcept
ScratchResource scratch_resource() const noexcept
const std::shared_ptr< ScratchSlab > & scratch_owner() const noexcept
int descriptor() const noexcept
ScratchFile(const ScratchFile &)=delete
void read(std::uint64_t offset, void *data, std::size_t length) const
std::string resolve_scratch_directory(const std::string &configured, std::string &refusal, int &error, bool &proven_memory, Probe probe)
ReportScratchIoError ScratchIoError
bool scratch_filesystem_is_memory(std::uint64_t type) noexcept
std::string scratch_create_message(const char *operation, const std::string &directory, const std::string &selection_note)