PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
feed_projection.hpp
Go to the documentation of this file.
1#pragma once
2
6
7#include <algorithm>
8#include <array>
9#include <cstddef>
10#include <cstdint>
11#include <cstring>
12#include <exception>
13#include <initializer_list>
14#include <limits>
15#include <memory>
16#include <new>
17#include <optional>
18#include <stdexcept>
19#include <string>
20#include <type_traits>
21#include <utility>
22
23namespace pineforge::detail {
24
25// Inclusive optional payload + page-tag budget, per projection reader.
26// Transfer buffers and the fixed owner object are reported separately.
27inline constexpr std::size_t kFeedProjectionCacheBytes = 256 * 1024;
28
30 std::uint64_t read_calls = 0;
31 std::uint64_t hits = 0;
32 std::uint64_t misses = 0;
33 std::uint64_t evictions = 0;
34 std::uint64_t bypass_reads = 0;
35 std::uint64_t allocation_bypasses = 0;
36 std::uint64_t backing_reads = 0;
37 std::uint64_t backing_bytes = 0;
38 std::uint64_t backing_errors = 0;
39 std::size_t resident_bytes = 0;
40 std::size_t peak_resident_bytes = 0;
41};
42
43template<class Row> class FeedProjectionBuilder;
44
45// Read-only whole-record adapter over the shared cache. Copies share only
46// the immutable provider, never cache addresses. The cache control is inline;
47// its only optional allocation is the shared cache's payload and metadata.
48template<class Row>
49class FeedProjection final : public FeedReader<Row> {
50 static_assert(std::is_trivially_copyable_v<Row>);
51 static_assert(std::is_nothrow_default_constructible_v<Row>);
52 static_assert(sizeof(Row) <= 4096);
53 static constexpr std::size_t kRows = 4096 / sizeof(Row);
54public:
55 FeedProjection() = default;
56 explicit FeedProjection(std::shared_ptr<const FeedReader<Row>> reader,
58 : reader_(std::move(reader)) {
60 }
61 FeedProjection(std::initializer_list<Row> rows);
63 : FeedProjection(other.reader_, other.capacity_bytes_) {}
65 if (this != &other) {
66 FeedProjection copy(other);
67 *this = std::move(copy);
68 }
69 return *this;
70 }
72 : reader_(std::move(other.reader_)),
73 capacity_bytes_(other.capacity_bytes_), stats_(other.stats_) {
74 // The shared cache is nonmovable and borrows this adapter. Rebuild a
75 // cold control object; immutable provider identity and values survive.
76 stats_.resident_bytes = 0;
77 initialize_cache();
78 other.cache_.reset();
79 other.read_failure_ = nullptr;
80 other.stats_.resident_bytes = 0;
81 }
83 if (this != &other) {
84 cache_.reset();
85 read_failure_ = nullptr;
86 reader_ = std::move(other.reader_);
87 capacity_bytes_ = other.capacity_bytes_;
88 stats_ = other.stats_;
89 stats_.resident_bytes = 0;
90 initialize_cache();
91 other.cache_.reset();
92 other.read_failure_ = nullptr;
93 other.stats_.resident_bytes = 0;
94 }
95 return *this;
96 }
97
98 static constexpr std::size_t page_payload_bytes() noexcept { return kRows * sizeof(Row); }
99 static std::size_t page_bytes() noexcept {
101 }
102 static constexpr std::size_t transfer_workspace_bytes() noexcept {
103 return page_payload_bytes();
104 }
105 static constexpr std::size_t rows_per_page() noexcept { return kRows; }
106 std::size_t size() const noexcept override { return reader_ ? reader_->size() : 0; }
107 bool empty() const noexcept { return size() == 0; }
108 Row operator[](std::size_t index) const { return this->at(index); }
109 Row back() const {
110 if (empty()) throw std::out_of_range("feed projection back");
111 return this->at(size() - 1);
112 }
113 const FeedProjectionStats& stats() const noexcept { return stats_; }
114 // Shared-cache counters cover the current capacity epoch. The projection
115 // counters above retain completed epochs across resizing and moves.
117 return cache_ ? cache_->stats() : ReadThroughCacheStats{};
118 }
120 return cache_ ? cache_->policy() : ReadThroughCachePolicy{0, page_payload_bytes()};
121 }
122 std::size_t capacity_bytes() const noexcept { return capacity_bytes_; }
123 const std::shared_ptr<const FeedReader<Row>>& reader() const noexcept { return reader_; }
124
125 // Zero/sub-page budgets and optional allocation failures all read through.
126 // No logical row limit follows from the number of allocated cache slots.
127 void set_capacity_bytes(std::size_t bytes) noexcept {
128 cache_.reset();
129 read_failure_ = nullptr;
130 stats_.resident_bytes = 0;
131 capacity_bytes_ = bytes;
132 initialize_cache();
133 }
134
135 void read(std::size_t first, Row* destination,
136 std::size_t count) const override {
137 if (first > size() || count > size() - first)
138 throw std::out_of_range("feed projection range");
139 if (count && !destination)
140 throw std::invalid_argument("feed projection destination");
141 if (count == 0) return;
142 if (count > std::numeric_limits<std::size_t>::max() / sizeof(Row))
143 throw AuxiliaryScratchIoError(EFBIG, "feed projection transfer");
144 increment(stats_.read_calls);
145 // Typed, aligned objects belong to this call. No pointer into a cache
146 // payload escapes, including on a hit, bypass or failed page fill.
147 std::array<Row, kRows> page;
148 while (count != 0) {
149 const auto page_first = first - first % kRows;
150 const auto rows = std::min(kRows, size() - page_first);
151 const auto before = cache_->stats();
152 read_failure_ = nullptr;
153 // One immutable provider per cache; the identity/generation
154 // namespace is local to this control object and never reused.
155 const auto status = cache_->get(
156 {0, 0, static_cast<std::uint64_t>(first / kRows)},
157 {reinterpret_cast<std::byte*>(page.data()), rows * sizeof(Row)});
158 record_cache_delta(before);
159 if (status != ScratchStatus::ok) {
160 if (read_failure_)
161 std::rethrow_exception(std::exchange(read_failure_, {}));
162 throw std::logic_error("feed projection cache contract");
163 }
164 const auto copied = std::min(count, kRows - (first - page_first));
165 std::memcpy(destination, page.data() + (first - page_first),
166 copied * sizeof(Row));
167 destination += copied;
168 first += copied;
169 count -= copied;
170 }
171 }
172private:
173 static void increment(std::uint64_t& counter, std::uint64_t amount = 1) noexcept {
174 counter += std::min(amount, std::numeric_limits<std::uint64_t>::max() - counter);
175 }
176 void initialize_cache() noexcept {
177 if (!reader_ || size() == 0) return;
178 const auto useful = size() / kRows + (size() % kRows != 0);
179 const auto slots = std::min(capacity_bytes_ / page_bytes(), useful);
180 // Multiplication is bounded by capacity_bytes_, including all tags.
181 cache_.emplace(ReadThroughCachePolicy{
182 slots * page_bytes(), page_payload_bytes(),
185 ReadThroughCacheBacking{this, read_page, nullptr});
186 }
187 static ReadThroughCacheIoResult read_page(
188 void* context, ReadThroughCacheKey key, ScratchBuffer destination) noexcept {
189 auto& self = *static_cast<FeedProjection*>(context);
190 if (key.backing_identity || key.generation
191 || key.page_number > std::numeric_limits<std::size_t>::max() / kRows
192 || destination.size % sizeof(Row) != 0)
194 try {
195 // get() supplies the aligned, already-live Row array above.
196 // The provider remains the authority for extent and real I/O.
197 self.read_backing(static_cast<std::size_t>(key.page_number) * kRows,
198 reinterpret_cast<Row*>(destination.data),
199 destination.size / sizeof(Row));
200 return {ScratchStatus::ok, destination.size};
201 } catch (...) {
202 // FeedReader's exception vocabulary includes auxiliary scratch,
203 // arena/slab failures and provider exceptions beyond the cache's
204 // carried report-error type. Rethrow the exact exception outside
205 // the noexcept port. A failed fill never publishes a cache tag.
206 self.read_failure_ = std::current_exception();
207 return {ScratchStatus::io_error, 0};
208 }
209 }
210 void record_cache_delta(const ReadThroughCacheStats& before) const noexcept {
211 const auto after = cache_->stats();
212 increment(stats_.hits, after.hits - before.hits);
213 increment(stats_.misses, after.misses - before.misses);
214 increment(stats_.evictions, after.evictions - before.evictions);
215 increment(stats_.bypass_reads, after.bypass_reads - before.bypass_reads);
216 increment(stats_.allocation_bypasses,
217 after.allocation_bypass_count - before.allocation_bypass_count);
218 stats_.resident_bytes = static_cast<std::size_t>(after.current_bytes);
219 stats_.peak_resident_bytes = std::max(
220 stats_.peak_resident_bytes, static_cast<std::size_t>(after.peak_bytes));
221 }
222 void read_backing(std::size_t first, Row* destination, std::size_t count) const {
223 increment(stats_.backing_reads);
224 try {
225 reader_->read(first, destination, count);
226 } catch (...) {
227 increment(stats_.backing_errors);
228 throw;
229 }
230 increment(stats_.backing_bytes, static_cast<std::uint64_t>(count) * sizeof(Row));
231 }
232 std::shared_ptr<const FeedReader<Row>> reader_;
233 mutable std::optional<ReadThroughCache> cache_;
234 mutable std::exception_ptr read_failure_;
235 std::size_t capacity_bytes_ = kFeedProjectionCacheBytes;
236 mutable FeedProjectionStats stats_;
237};
238
239// Immutable positional provider. Physical counters are at the ScratchFile
240// transfer, below the optional cache, so tests can prove a cold read occurred.
241template<class Row>
242class ScratchFeedReader final : public FeedReader<Row> {
243 static_assert(std::is_trivially_copyable_v<Row>);
244 friend class FeedProjectionBuilder<Row>;
245public:
246 explicit ScratchFeedReader(const std::string& directory = {})
247 : file_(ScratchFile::IoRaiser::auxiliary) {
248 file_.bound_cache();
249 file_.set_directory(directory);
250 }
251 std::size_t size() const noexcept override { return count_; }
252 void read(std::size_t first, Row* destination,
253 std::size_t count) const override {
254 if (first > count_ || count > count_ - first)
255 throw std::out_of_range("scratch feed range");
256 if (count && !destination)
257 throw std::invalid_argument("scratch feed destination");
258 if (count == 0) return;
259 if (count > std::numeric_limits<std::size_t>::max() / sizeof(Row))
260 throw AuxiliaryScratchIoError(EFBIG, "feed projection transfer");
261 ++read_calls_;
262 file_.read(byte_offset(first), destination, count * sizeof(Row));
263 read_bytes_ += static_cast<std::uint64_t>(count) * sizeof(Row);
264 }
265 std::uint64_t read_calls() const noexcept { return read_calls_; }
266 std::uint64_t read_bytes() const noexcept { return read_bytes_; }
267private:
268 static std::uint64_t byte_offset(std::size_t index) {
269 if (index > std::numeric_limits<std::uint64_t>::max() / sizeof(Row))
270 throw AuxiliaryScratchIoError(EFBIG, "feed projection offset");
271 return static_cast<std::uint64_t>(index) * sizeof(Row);
272 }
273 ScratchFile file_;
274 std::size_t count_ = 0;
275 mutable std::uint64_t read_calls_ = 0;
276 mutable std::uint64_t read_bytes_ = 0;
277};
278
279// Immutable provider for a production projection that fits in its one
280// transfer page. The owner is bounded by Row and never grows with input.
281template<class Row>
282class ResidentFeedReader final : public FeedReader<Row> {
283 static_assert(std::is_trivially_copyable_v<Row>);
284 static_assert(std::is_nothrow_default_constructible_v<Row>);
285 static_assert(sizeof(Row) <= 4096);
286 static constexpr std::size_t kRows = 4096 / sizeof(Row);
287public:
288 ResidentFeedReader(const Row* rows, std::size_t count) : count_(count) {
289 if (count > kRows)
290 throw std::invalid_argument("resident feed projection extent");
291 if (count != 0)
292 std::memcpy(rows_.data(), rows, count * sizeof(Row));
293 }
294 std::size_t size() const noexcept override { return count_; }
295 void read(std::size_t first, Row* destination,
296 std::size_t count) const override {
297 if (first > count_ || count > count_ - first)
298 throw std::out_of_range("resident feed range");
299 if (count && !destination)
300 throw std::invalid_argument("resident feed destination");
301 if (count == 0) return;
302 std::memcpy(destination, rows_.data() + first, count * sizeof(Row));
303 }
304private:
305 std::array<Row, kRows> rows_{};
306 std::size_t count_ = 0;
307};
308
309// Sequential production uses one mandatory transfer page, independent of
310// input length and optional read capacity. Resident mode publishes that page
311// in memory until a later append requires scratch; finish() flushes any
312// pending scratch page, and destruction never flushes or commits.
313template<class Row>
315 static_assert(std::is_trivially_copyable_v<Row>);
316 static_assert(sizeof(Row) <= 4096);
317 static constexpr std::size_t kRows = 4096 / sizeof(Row);
318public:
319 explicit FeedProjectionBuilder(const std::string& directory = {},
320 bool resident_if_single_page = false)
321 : directory_(directory),
322 resident_if_single_page_(resident_if_single_page) {
323 if (!resident_if_single_page_)
324 reader_ = std::make_shared<ScratchFeedReader<Row>>(directory_);
325 }
328
329 void append(const Row& row) {
330 if (published_) throw std::logic_error("feed projection already published");
331 if (resident_if_single_page_ && !reader_ && buffered_ == kRows)
332 spill_to_scratch();
333 if (buffered_ == kRows) flush();
334 if (reader_) {
335 if (reader_->count_ > std::numeric_limits<std::size_t>::max()
336 - buffered_ - 1)
337 throw AuxiliaryScratchIoError(EFBIG, "feed projection extent");
338 const auto offset =
339 ScratchFeedReader<Row>::byte_offset(reader_->count_ + buffered_);
340 ScratchFile::check_extent(offset, sizeof(Row), false,
342 }
343 std::memcpy(&buffer_[buffered_++], &row, sizeof(Row));
344 }
346 if (published_) throw std::logic_error("feed projection already published");
347 std::shared_ptr<const FeedReader<Row>> provider;
348 if (reader_) {
349 flush();
350 provider = reader_;
351 } else {
352 provider = std::make_shared<ResidentFeedReader<Row>>(buffer_.data(), buffered_);
353 }
354 FeedProjection<Row> result(std::move(provider), capacity_bytes);
355 published_ = true;
356 reader_.reset();
357 return result;
358 }
359private:
360 void spill_to_scratch() {
361 reader_ = std::make_shared<ScratchFeedReader<Row>>(directory_);
362 flush();
363 }
364
365 void flush() {
366 if (buffered_ == 0) return;
367 reader_->file_.open("pineforge-feed");
368 reader_->file_.write(ScratchFeedReader<Row>::byte_offset(reader_->count_),
369 buffer_.data(), buffered_ * sizeof(Row));
370 // Retain the entire transfer page if a write fails.
371 reader_->count_ += buffered_;
372 buffered_ = 0;
373 }
374 std::string directory_;
375 bool resident_if_single_page_ = false;
376 bool published_ = false;
377 std::shared_ptr<ScratchFeedReader<Row>> reader_;
378 std::array<Row, kRows> buffer_;
379 std::size_t buffered_ = 0;
380};
381
382template<class Row>
383FeedProjection<Row>::FeedProjection(std::initializer_list<Row> rows) {
385 for (const auto& row : rows) builder.append(row);
386 *this = builder.finish();
387}
388
389} // namespace pineforge::detail
Row at(std::size_t index) const
FeedProjectionBuilder(const std::string &directory={}, bool resident_if_single_page=false)
FeedProjectionBuilder(const FeedProjectionBuilder &)=delete
FeedProjection< Row > finish(std::size_t capacity_bytes=kFeedProjectionCacheBytes)
FeedProjectionBuilder & operator=(const FeedProjectionBuilder &)=delete
const std::shared_ptr< const FeedReader< Row > > & reader() const noexcept
static std::size_t page_bytes() noexcept
Row operator[](std::size_t index) const
void set_capacity_bytes(std::size_t bytes) noexcept
std::size_t capacity_bytes() const noexcept
const FeedProjectionStats & stats() const noexcept
static constexpr std::size_t page_payload_bytes() noexcept
FeedProjection & operator=(const FeedProjection &other)
static constexpr std::size_t transfer_workspace_bytes() noexcept
ReadThroughCacheStats cache_stats() const noexcept
FeedProjection(const FeedProjection &other)
std::size_t size() const noexcept override
void read(std::size_t first, Row *destination, std::size_t count) const override
FeedProjection(std::shared_ptr< const FeedReader< Row > > reader, std::size_t capacity_bytes=kFeedProjectionCacheBytes)
FeedProjection(FeedProjection &&other) noexcept
ReadThroughCachePolicy cache_policy() const noexcept
static constexpr std::size_t rows_per_page() noexcept
FeedProjection(std::initializer_list< Row > rows)
FeedProjection & operator=(FeedProjection &&other) noexcept
static std::size_t line_bytes(std::size_t page_size) noexcept
std::size_t size() const noexcept override
void read(std::size_t first, Row *destination, std::size_t count) const override
ResidentFeedReader(const Row *rows, std::size_t count)
std::uint64_t read_bytes() const noexcept
std::size_t size() const noexcept override
std::uint64_t read_calls() const noexcept
ScratchFeedReader(const std::string &directory={})
void read(std::size_t first, Row *destination, std::size_t count) const override
static void check_extent(std::uint64_t offset, std::size_t length, bool arena=false, IoRaiser raiser=IoRaiser::report_sink)
void set_directory(const std::string &value)
constexpr std::size_t kFeedProjectionCacheBytes