PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
disk_id_table.hpp
Go to the documentation of this file.
1#pragma once
2
5
6#include <algorithm>
7#include <cerrno>
8#include <cstddef>
9#include <cstdint>
10#include <limits>
11#include <optional>
12#include <string>
13#include <type_traits>
14#include <utility>
15
16namespace pineforge::detail {
17
18// An AVL tree of byte-string keys and fixed-size values in positional scratch.
19// A node is 56 + sizeof(Value) bytes followed by the key bytes. Parent links
20// allow rotations and sorted walks with O(1) RAM, including adversarial ids.
21// The independent next link preserves first insertion order. Updating a value
22// overwrites its slot: repeated ids cost no additional disk or resident heap.
23// File offsets/tree links are storage metadata; callers fold logical values.
24template<class Value>
26 static_assert(std::is_trivially_copyable_v<Value>, "disk id values are fixed records");
27 struct Links {
28 std::uint64_t left = 0, right = 0, parent = 0, next = 0;
29 std::uint64_t key_size = 0, height = 1, reserved = 0;
30 };
31 static_assert(sizeof(Links) == 56, "scratch id node layout");
32
33public:
34 using Offset = std::uint64_t;
35 DiskIdTable() = default;
36 explicit DiskIdTable(std::size_t cache_bytes) : read_cache_(cache_bytes) {}
37 explicit DiskIdTable(IdTableReadCache::Policy cache) : read_cache_(cache) {}
38 explicit DiskIdTable(CacheCapacity capacity)
39 : DiskIdTable(IdTableReadCache::policy_for_capacity(capacity)) {}
41 read_cache_.set_cache_capacity(capacity);
42 }
43 DiskIdTable(const DiskIdTable&) = delete;
45 DiskIdTable(DiskIdTable&& other) noexcept { *this = std::move(other); }
46 DiskIdTable& operator=(DiskIdTable&& other) noexcept {
47 if (this != &other) {
48 read_cache_ = std::move(other.read_cache_); // Both owners lose every valid line.
49 file_ = std::move(other.file_);
50 root_ = std::exchange(other.root_, 0);
51 first_ = std::exchange(other.first_, 0);
52 last_ = std::exchange(other.last_, 0);
53 end_ = std::exchange(other.end_, 1);
54 size_ = std::exchange(other.size_, 0);
55 }
56 return *this;
57 }
58 void set_store(ScratchStore store) noexcept { file_.set_store(store); }
59 bool arena_backed() const noexcept { return file_.arena_backed(); }
60 bool bounded_cohorts() const noexcept {
61 return file_.scratch_owner() && file_.scratch_resource() == ScratchResource::cohort;
62 }
63 void set_directory(const std::string& directory) { file_.set_directory(directory); }
64 void bind_scratch(const std::shared_ptr<ScratchSlab>& slab, ScratchResource resource) {
65 if (size_ != 0)
67 "scratch id backend changed after use");
70 "scratch id table requires a tree region");
71 read_cache_.release();
72 file_.bind_scratch(slab, resource);
73 }
74 void clear() noexcept {
75 read_cache_.release();
76 file_.clear();
77 root_ = first_ = last_ = size_ = 0;
78 end_ = 1; // offset zero is the null link
79 }
80 std::uint64_t size() const noexcept { return size_; }
81 std::uint64_t file_bytes() const noexcept { return size_ == 0 ? 0 : end_; }
82 Offset first_inserted() const noexcept { return first_; }
83 Offset next_inserted(Offset at) const { return links(at).next; }
84 std::string key(Offset at) const { return read_key(at, links(at)); }
85 Offset first_sorted() const { return leftmost(root_); }
87 auto row = links(at);
88 if (row.right != 0) return leftmost(row.right);
89 Offset child = at;
90 at = row.parent;
91 while (at != 0) {
92 row = links(at);
93 if (row.left == child) break;
94 child = at;
95 at = row.parent;
96 }
97 return at;
98 }
99
100 std::optional<Offset> find(const std::string& key) const {
101 Offset at = root_;
102 while (at != 0) {
103 const auto row = links(at);
104 const int order = key.compare(read_key(at, row));
105 if (order == 0) return at;
106 at = order < 0 ? row.left : row.right;
107 }
108 return std::nullopt;
109 }
110 // Read-only whole-node ticket, also used before a caller spills resident
111 // facts or accepts a kernel command. Duplicates need no count/byte growth.
112 // Disk mode deliberately retains its existing search and I/O behaviour.
113 void require_insert(const std::string& key) const {
114 if (!file_.scratch_owner() || find(key)) return;
115 require_new_key(key.size());
116 }
117 // enumerate(visit) replays a stable, borrowed workset. Count each absent
118 // key once without a growing side index: O(workset^2) comparisons, O(1)
119 // retained scratch. No caller may mutate the workset during this ticket.
120 template<class Enumerate> void require_inserts(Enumerate&& enumerate) const {
121 if (!file_.scratch_owner()) return;
122 std::uint64_t ordinal = 0, extra_rows = 0, extra_bytes = 0;
123 enumerate([&](const std::string& key) {
124 require_id_size(key.size());
125 const auto current = ordinal++;
126 if (find(key)) return;
127 bool repeated = false;
128 std::uint64_t prior = 0;
129 enumerate([&](const std::string& other) {
130 if (prior++ < current && other == key) repeated = true;
131 });
132 if (repeated) return;
133 const auto maximum = std::numeric_limits<std::uint64_t>::max();
134 if (extra_rows == maximum || key.size() > maximum - extra_bytes) {
135 const auto resource = file_.scratch_resource();
136 const auto& manifest = file_.scratch_owner()->manifest();
137 throw ScratchCapacityError(ids_total_label(resource), extra_rows == maximum
139 resource == ScratchResource::cohort ? manifest.U : manifest.V)
141 resource == ScratchResource::cohort ? manifest.B_U : manifest.B_V));
142 }
143 ++extra_rows;
144 extra_bytes += key.size();
145 require_growth(extra_rows, extra_bytes);
146 });
147 }
148 Value get(Offset at) const {
149 validate(at);
150 Value value{};
151 read(at + sizeof(Links), &value, sizeof value);
152 return value;
153 }
154 void set(Offset at, const Value& value) {
155 validate(at);
156 write(at + sizeof(Links), &value, sizeof value);
157 }
158 std::pair<Offset, bool> insert(const std::string& key, const Value& value) {
159 Offset parent = 0;
160 Offset at = root_;
161 bool left = false;
162 while (at != 0) {
163 const auto row = links(at);
164 const int order = key.compare(read_key(at, row));
165 if (order == 0) return {at, false};
166 parent = at;
167 left = order < 0;
168 at = left ? row.left : row.right;
169 }
170 require_new_key(key.size());
171 constexpr std::size_t header = sizeof(Links) + sizeof(Value);
172 const bool arena = file_.opened() ? file_.arena_backed() : ScratchArena::attached() != nullptr;
175 if (size_ == std::numeric_limits<std::uint64_t>::max())
176 throw AuxiliaryScratchIoError(EFBIG, "scratch id count exhausted");
177 file_.open("pineforge-ids");
178 at = end_;
179 Links row;
180 row.parent = parent;
181 row.key_size = key.size();
182 write(at, &row, sizeof row);
183 write(at + sizeof(Links), &value, sizeof value);
184 write(at + header, key.data(), key.size());
185 // All data bytes exist before linking the row into the logical table.
186 // Any partial link/rotation I/O error fails the run; it is never
187 // converted to a successful absent lookup or retried as another id.
188 end_ += header + key.size();
189 if (parent != 0) {
190 auto old = links(parent);
191 (left ? old.left : old.right) = at;
192 put_links(parent, old);
193 } else root_ = at;
194 if (last_ != 0) {
195 auto previous = links(last_);
196 previous.next = at;
197 put_links(last_, previous);
198 } else first_ = at;
199 last_ = at;
200 ++size_;
201 rebalance(parent);
202 return {at, true};
203 }
204 template<class F> void for_each_sorted(F&& visit) const {
205 for (Offset at = first_sorted(); at != 0; at = next_sorted(at))
206 visit(key(at), get(at));
207 }
208 template<class F> void for_each_inserted(F&& visit) const {
209 for (Offset at = first_; at != 0;) {
210 const auto row = links(at);
211 visit(read_key(at, row), get(at));
212 at = row.next;
213 }
214 }
215
216private:
217 static constexpr ScratchResource ids_total_label(ScratchResource resource) noexcept {
219 }
220
221 void read(Offset offset, void* data, std::size_t length) const {
222 // disk_backed() is false for any arena, even an unjailed file arena,
223 // and for a slab. Never use the attached arena's inherited fd here.
224 read_cache_.read(file_, file_.disk_backed(), end_, 0, offset, data, length);
225 }
226 void write(Offset offset, const void* data, std::size_t length) {
227 if (file_.disk_backed()) read_cache_.before_write(0, end_, offset, length);
228 file_.write(offset, data, length); // Always immediate, including errors.
229 }
230 void require_id_size(std::size_t key_size) const {
231 if (file_.scratch_owner() && file_.scratch_resource() == ScratchResource::cohort
232 && key_size > 64)
233 throw ScratchCapacityError(ScratchResource::cohort, "id longer than 64 bytes");
234 }
235 void require_new_key(std::size_t key_size) const {
236 require_id_size(key_size);
237 require_growth(1, key_size);
238 }
239 void require_growth(std::uint64_t extra_rows, std::uint64_t extra_bytes) const {
240 const auto& slab = file_.scratch_owner();
241 if (!slab) return;
242 const auto resource = file_.scratch_resource();
243 const auto& manifest = slab->manifest();
244 const auto rows = resource == ScratchResource::cohort ? manifest.U : manifest.V;
245 const auto bytes = resource == ScratchResource::cohort ? manifest.B_U : manifest.B_V;
246 constexpr std::uint64_t header = sizeof(Links) + sizeof(Value);
247 // Packed nodes have no inter-row alignment: the append cursor minus
248 // the null byte and fixed headers is exactly aggregate stored key bytes.
249 const auto key_bytes = end_ - 1 - header * size_;
250 if (size_ > rows || extra_rows > rows - size_)
251 throw ScratchCapacityError(ids_total_label(resource),
253 if (key_bytes > bytes || extra_bytes > bytes - key_bytes)
254 throw ScratchCapacityError(ids_total_label(resource),
256 const auto capacity = slab->region(resource).capacity;
257 if (end_ > capacity || extra_rows > (capacity - end_) / header)
258 throw ScratchCapacityError(ids_total_label(resource),
260 const auto fixed_bytes = header * extra_rows;
261 if (extra_bytes > capacity - end_ - fixed_bytes)
262 throw ScratchCapacityError(ids_total_label(resource),
264 slab->require_extend(resource, end_ + fixed_bytes + extra_bytes);
265 }
266 [[noreturn]] void io_error(int error, const char* operation) const {
267 throw AuxiliaryScratchIoError(error, operation);
268 }
269 Offset leftmost(Offset at) const {
270 while (at != 0) {
271 const auto row = links(at);
272 if (row.left == 0) break;
273 at = row.left;
274 }
275 return at;
276 }
277 void validate(Offset at) const {
278 if (at == 0 || at >= end_ || end_ - at < sizeof(Links) + sizeof(Value))
279 io_error(EIO, "scratch id invalid offset");
280 }
281 Links links(Offset at) const {
282 validate(at);
283 Links row{};
284 read(at, &row, sizeof row);
285 if (row.height == 0 || row.height > 128
286 || row.key_size > end_ - at - sizeof(Links) - sizeof(Value))
287 io_error(EIO, "scratch id invalid node");
288 return row;
289 }
290 void put_links(Offset at, const Links& row) { write(at, &row, sizeof row); }
291 std::string read_key(Offset at, const Links& row) const {
292 if (row.key_size > std::string{}.max_size())
293 io_error(EFBIG, "scratch id key too long");
294 std::string key(static_cast<std::size_t>(row.key_size), '\0');
295 read(at + sizeof(Links) + sizeof(Value), key.data(), key.size());
296 return key;
297 }
298 std::uint64_t height(Offset at) const { return at == 0 ? 0 : links(at).height; }
299 void refresh(Links& row) const { row.height = 1 + std::max(height(row.left), height(row.right)); }
300 int balance(const Links& row) const {
301 return static_cast<int>(height(row.left)) - static_cast<int>(height(row.right));
302 }
303 Offset rotate(Offset at, bool left) {
304 auto old = links(at);
305 const Offset top_at = left ? old.right : old.left;
306 auto top = links(top_at);
307 const Offset middle_at = left ? top.left : top.right;
308 const Offset parent_at = old.parent;
309 if (parent_at != 0) {
310 auto parent = links(parent_at);
311 (parent.left == at ? parent.left : parent.right) = top_at;
312 put_links(parent_at, parent);
313 } else root_ = top_at;
314 top.parent = parent_at;
315 old.parent = top_at;
316 (left ? old.right : old.left) = middle_at;
317 if (middle_at != 0) {
318 auto middle = links(middle_at);
319 middle.parent = at;
320 put_links(middle_at, middle);
321 }
322 refresh(old);
323 put_links(at, old);
324 (left ? top.left : top.right) = at;
325 refresh(top);
326 put_links(top_at, top);
327 return top_at;
328 }
329 void rebalance(Offset at) {
330 while (at != 0) {
331 auto row = links(at);
332 refresh(row);
333 put_links(at, row);
334 const int lean = balance(row);
335 if (lean > 1) {
336 if (balance(links(row.left)) < 0) (void)rotate(row.left, true);
337 at = rotate(at, false);
338 } else if (lean < -1) {
339 if (balance(links(row.right)) > 0) (void)rotate(row.right, false);
340 at = rotate(at, true);
341 }
342 at = links(at).parent;
343 }
344 }
345
346 ScratchFile file_{ScratchFile::IoRaiser::auxiliary};
347 IdTableReadCache read_cache_;
348 Offset root_ = 0, first_ = 0, last_ = 0, end_ = 1;
349 std::uint64_t size_ = 0;
350};
351} // namespace pineforge::detail
#define F(name)
Base with a code: throw coded<std::invalid_argument>(code, {args}, text).
std::pair< Offset, bool > insert(const std::string &key, const Value &value)
void for_each_sorted(F &&visit) const
Offset next_inserted(Offset at) const
void for_each_inserted(F &&visit) const
void require_insert(const std::string &key) const
std::uint64_t file_bytes() const noexcept
Offset next_sorted(Offset at) const
void set_directory(const std::string &directory)
void set(Offset at, const Value &value)
std::string key(Offset at) const
void require_inserts(Enumerate &&enumerate) const
DiskIdTable(CacheCapacity capacity)
void set_cache_capacity(CacheCapacity capacity)
bool bounded_cohorts() const noexcept
DiskIdTable & operator=(const DiskIdTable &)=delete
DiskIdTable(IdTableReadCache::Policy cache)
void bind_scratch(const std::shared_ptr< ScratchSlab > &slab, ScratchResource resource)
DiskIdTable(const DiskIdTable &)=delete
DiskIdTable & operator=(DiskIdTable &&other) noexcept
std::optional< Offset > find(const std::string &key) const
void set_store(ScratchStore store) noexcept
DiskIdTable(std::size_t cache_bytes)
std::uint64_t size() const noexcept
DiskIdTable(DiskIdTable &&other) noexcept
Offset first_inserted() const noexcept
bool arena_backed() const noexcept
void read(const File &source, bool plain_disk, std::uint64_t logical_end, unsigned file, std::uint64_t offset, void *destination, std::size_t length) const
void before_write(unsigned file, std::uint64_t logical_end, std::uint64_t offset, std::size_t length) const noexcept
static ScratchArena * attached() noexcept
static void check_extent(std::uint64_t offset, std::size_t length, bool arena=false, IoRaiser raiser=IoRaiser::report_sink)
bool disk_backed() const noexcept
void write(std::uint64_t offset, const void *data, std::size_t length)
std::string scratch_count_limit_message(ScratchResource resource, std::uint64_t limit)
std::string scratch_byte_limit_message(ScratchResource resource, std::uint64_t limit)