PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
o1_store.hpp
Go to the documentation of this file.
1// O(1) resident script-state store contract.
2//
3// PURPOSE. Script-owned state (arrays, strings, map payloads, matrices, UDT graphs,
4// drawing records) grows with bars or with objects the script creates. This header
5// defines the shared store interface so that state lives in a fixed
6// resident working set over a spilling backing, addressed by stable ids, with exact
7// transactional versions for calc-on-fill rollback.
8//
9// STORE GUARANTEES:
10// - A write or resize never fails because the resident window is full; it spills.
11// Creation is bounded by Shape::max_live_objects, independent of lifetime ids.
12// - Resident workspace plus kStoreReadCacheBytes is fixed at open,
13// independent of bars B and of objects created.
14// - Ids are stable and never recycled while reachable; a released id is dead, and
15// reads of a dead id are a caller error, not a silent zero.
16// - Transaction rollback restores every touched root byte-exactly; version chains
17// live in the backing, never in a growing in-heap copy.
18// - Every read and write is exact: no approximation, no dropped old element, no
19// reordering that a script can observe.
20//
21// IMPLEMENTATION SHAPE. This interface is a THIN
22// OBJECT LAYER over pineforge::detail::ScratchStore, so the project has one page
23// cache and one backing boundary. An object is a header record plus fixed-size data
24// chunk records, each record keyed by the 16-byte big-endian pair (object id, chunk
25// number) and ordered by a plain bytewise compare. Chunk 0's value is the 16-byte
26// header (logical size, alignment) followed by that chunk's data bytes; every later
27// chunk's value is its data bytes. Creation records only the header chunk.
28// Unmaterialized data chunks read as zero and are published on first write. The
29// layer keeps no per-object, per-chunk or per-byte heap state: an id counter,
30// a few scalars and fixed buffers only.
31//
32// DECLARATIONS ONLY. The single implementation is src/detail/o1_store.cpp;
33// all store consumers use this header rather than separate implementations.
34
35#ifndef PINEFORGE_DETAIL_O1_STORE_HPP
36#define PINEFORGE_DETAIL_O1_STORE_HPP
37
38#include <cstddef>
39#include <cstdint>
40#include <memory>
41#include <stdexcept>
42
44
45namespace pineforge {
46namespace detail {
47namespace o1 {
48
49using Id = std::uint64_t;
50inline constexpr Id kNullId = UINT64_C(0);
51
52// Fixed chunk-byte and resident-page-COUNT bounds, not a resident-byte cap.
53// ScratchStore resident = (resident_pages + 6 work pages + metadata in pages)
54// x page_bytes: at most about 1 GiB at its own 1 MiB page-size limit.
55// Larger chunk_bytes/resident_pages are caller errors. These fixed limits
56// bound the supported chunk size and resident page count, respectively.
57inline constexpr std::uint32_t kMaxChunkBytes = 64u * 1024u;
58inline constexpr std::uint32_t kMaxResidentPages = 1024u;
59// Live-object default and cap: 1,048,576. Zero selects the default; larger
60// values clamp to the cap, and smaller explicit values are kept.
61inline constexpr std::uint64_t kMaxLiveObjectsCap = UINT64_C(1024) * 1024;
62
63// Caller budget: outer script scope + joined collection + graph operation +
64// one headroom slot. Child container operations join without another handle,
65// independent of container depth. Finished handles occupy slots until destroyed;
66// the one cleanup slot is separate. This is an internal bug guard, unreachable
67// from a valid script, rather than a limit on objects or nesting.
68inline constexpr std::size_t kMaxTxHandles = 4;
69
70// Two fixed direct-mapped tables per StoreImpl: decoded headers and verified
71// chunk handles, eight slots each. Each entry uses three 64-bit words.
72// No chunk payloads or population-sized directory are resident in this cache.
73inline constexpr std::size_t kStoreReadCacheSlots = 8;
74inline constexpr std::size_t kStoreReadCacheBytes =
75 kStoreReadCacheSlots * (3 * sizeof(std::uint64_t) + 3 * sizeof(std::uint64_t));
76
77class TxPoolExhausted : public std::runtime_error {
78 public:
80 : std::runtime_error("o1 store: transaction handle pool exhausted") {}
81};
82
83// Refusal of work in an already poisoned transaction, distinct from the
84// backing/codec failure that may have poisoned it. Thrown by the store only;
85// a codec must not throw it.
86class RollbackOnly : public std::logic_error {
87 public:
89 : std::logic_error("o1 store: transaction is rollback-only") {}
90};
91
93
94struct Shape {
95 std::uint32_t page_bytes = 4096; // ScratchStore page size; must equal the backing's
96 std::uint32_t resident_pages = 64; // ScratchStore resident cache pages; fixed at open
97 // EXT: one data chunk record covers this many object bytes, so it is the backing
98 // cost of one element write and the object's rounding granularity. Appended after
99 // the first contract; the fields above keep their meaning and defaults.
100 std::uint32_t chunk_bytes = 1024;
101 // EXT: ScratchStore requires these counts >= 1 (zero is normalized to 1 here).
102 // Otherwise passed through; ScratchStore's workspace sizing never reads them.
103 std::uint32_t journal_slots = 8;
104 std::uint32_t index_slots = 8;
105 // EXT: maximum live objects in the current transaction. Zero selects the
106 // default (kMaxLiveObjectsCap); larger values clamp to the cap at open,
107 // and smaller explicit values are kept.
109};
110
111struct Stats {
112 std::uint64_t bytes_live = 0; // logical bytes held by live ids
113 std::uint64_t bytes_resident = 0; // fixed ScratchStore workspace + Store read cache
114 std::uint64_t pages_spilled = 0; // backing pages currently allocated
115 std::uint64_t bytes_backing = 0; // file growth, never charged as resident
116 std::uint64_t max_live_handles = 0; // caller high-water, finished handles included
117 // Sticky diagnostics, never rolled back. Only cleanup_failed must be zero
118 // for a clean corpus run; rollback-only refusal is expected on valid scripts.
120 std::uint64_t cleanup_failed = 0;
121 std::uint64_t objects_live = 0; // live ids as seen by the current transaction
122 std::uint64_t max_live_objects = 0; // normalized Shape limit, fixed at open
123};
124
125// Transactional root for calc-on-fill rollback. All mutations between begin() and
126// commit() belong to a new version; rollback() restores the previous version of
127// every object exactly.
128//
129// One outer transaction at a time, flattened. begin() with no active transaction
130// maps to the ScratchStore transaction. begin() while one is active returns a
131// JOINED scope: its commit() is a no-op, its rollback() marks the outer transaction
132// rollback-only and undoes nothing by itself, and destroying it uncommitted marks
133// the outer transaction rollback-only too. While rollback-only begin() and every
134// mutation throw RollbackOnly (a std::logic_error); the outer commit() performs
135// the real rollback and then throws the same error; the outer rollback() always
136// works. There is no savepoint. A backing failure in any call, including a
137// read-only one, makes the open outer transaction rollback-only.
138// Destroy all Tx handles before the next
139// outer begin(): destroying an unfinished stale outer handle later can roll that
140// NEW transaction back.
141class Tx {
142 public:
143 virtual ~Tx();
144 virtual void commit() = 0;
145 virtual void rollback() = 0;
146};
147
148class TxScope;
149
150// Single-threaded: read() const mutates its key/chunk buffers and fixed cache.
151class Store {
152 public:
153 virtual ~Store();
154
155 // Opens a fresh object layer over one borrowed backing. The backing must be
156 // empty (create_empty) and its page size must equal Shape::page_bytes.
157 static std::unique_ptr<Store> open(const Shape&, ScratchBacking&);
158
159 // Object lifecycle. `bytes` is the initial logical size; `align` is a power of two.
160 // Creating beyond max_live_objects throws ScratchCapacityError (cohort) before
161 // any mutation. For a direct store create(), an open Tx stays usable. Through
162 // a container operation, the joined scope that saw the throw poisons the outer
163 // transaction; there is no savepoint.
164 virtual Id create(std::uint64_t bytes, std::uint64_t align) = 0;
165 virtual void release(Id) = 0; // id becomes dead; never reused
166 virtual bool alive(Id) const = 0;
167 virtual std::uint64_t size(Id) const = 0; // logical bytes
168
169 // Resize preserves contents up to min(old, new) and never refuses. Growth is
170 // backed by the backing, not by the resident window.
171 virtual bool resize(Id, std::uint64_t bytes) = 0;
172
173 // Exact page-granular access. Return the byte count moved; a short count means
174 // the request crossed the logical end, which the caller must treat as an error.
175 // An unmaterialized data chunk reads as zero bytes.
176 // A mutating call outside a transaction runs as its own one-call transaction.
177 virtual std::size_t read(Id, std::uint64_t off, void* dst, std::size_t n) const = 0;
178 virtual std::size_t write(Id, std::uint64_t off, const void* src, std::size_t n) = 0;
179
180 // Successful begin() calls allocate no heap memory: caller handles reuse a
181 // pool allocated at open(), with kMaxTxHandles simultaneously outstanding
182 // caller handles (including finished ones). Exhaustion throws TxPoolExhausted
183 // before opening or changing a transaction; there is no heap fallback.
184 // This guards an internal call-shape bug, unreachable from a valid script.
185 // Destructor cleanup uses begin_cleanup()'s separate reserved slot.
186 // Pool access and handle destruction are NOT thread-safe, like the store.
187 // Destroying a handle returns its slot. The store must outlive unfinished
188 // handles; finished handles may outlive it. Other successful operations
189 // reuse the open workspace.
190 virtual std::unique_ptr<Tx> begin() = 0;
191
192 static constexpr std::size_t max_caller_handles() noexcept {
193 return kMaxTxHandles;
194 }
195
196 // A container operation joins an active transaction without a pool handle.
197 // With no active transaction it owns one begin() handle. Commit, rollback,
198 // abandonment and rollback-only errors have the same meaning as for Tx.
199 // The returned stack scope must finish before its enclosing transaction.
201
202 // internal: for TxScope and forwarding wrappers only; calling
203 // abandon_joined_tx() poisons the enclosing transaction.
204 // TxScope calls these hooks on the Store it was created from. A forwarding
205 // wrapper must forward BOTH hooks to its inner store: joining checks for
206 // RollbackOnly and returns whether an outer transaction is active;
207 // abandonment marks that transaction rollback-only without a pool handle.
208 virtual bool join_active_tx() = 0;
209 virtual void abandon_joined_tx() noexcept = 0;
210
211 // Serialized destructor cleanup, with the same flattened transaction and
212 // rollback-only semantics as begin(). Uses one reserved slot allocated at
213 // open(), never available to begin(), and allocates no heap memory.
214 // Maximum handle depth: kMaxTxHandles caller handles plus ONE cleanup handle,
215 // including finished handles. Destroy the cleanup handle before another
216 // cleanup begins. Map destructors do so before nested member destruction;
217 // their stored-payload releases do not open cleanup handles.
218 // Reserved-slot exhaustion is an internal bug and throws TxPoolExhausted before
219 // changing transaction state. Backing and rollback-only failures propagate
220 // as for begin(). Pool access and handle destruction are NOT thread-safe.
221 // Unwinding an unfinished cleanup handle joined to an active outer transaction
222 // poisons that outer transaction (rollback-only).
223 // After rollback, surviving aliases/checkpoints keep the native references
224 // (map Snapshot retains Storage and restore() rebinds its receiver).
225 // Resurrected ids with no surviving native reference remain unreferenced, as
226 // do pre-existing objects whose last native reference dies while a
227 // rollback-only outer refuses cleanup. A refusal escaping a native
228 // recalculation callback fails the run (NativeExecutionConsumer::invoke_recalculation).
229 // If the host continues after a refused container operation, one object stays
230 // unreferenced until the run ends and cleanup_skipped_rollback_only is its trace.
231 virtual std::unique_ptr<Tx> begin_cleanup() = 0;
232
233 // Every map destructor that swallows a release exception records its reason:
234 // RollbackOnly is benign; every other exception is a failed cleanup. The
235 // string destructor swallows a failed release without recording it.
236 // Wrappers forward the reason unchanged.
237 virtual void note_cleanup_skipped(CleanupSkip reason) noexcept = 0;
238 virtual Stats stats() const = 0;
239
240 // Cache invalidation token, not transactional state. Advances whenever an
241 // outer transaction restores the previous version, including a rollback-only
242 // commit or abandonment. Joined rollbacks only poison the outer transaction;
243 // the token advances when that outer transaction actually rolls back.
244 // Forwarding wrappers must return the inner store's token unchanged.
245 virtual std::uint64_t rollback_epoch() const noexcept = 0;
246};
247
248// Allocation-free joined operation. An unfinished joined scope poisons its
249// outer transaction even when the failure came from a codec rather than I/O.
250class TxScope {
251 public:
252 TxScope(const TxScope&) = delete;
253 TxScope& operator=(const TxScope&) = delete;
255 void commit();
256 void rollback();
257
258 private:
259 friend class Store;
260 explicit TxScope(Store&);
261 void require_open() const;
262
263 Store* store_;
264 std::unique_ptr<Tx> owned_;
265 bool finished_ = false;
266};
267
268} // namespace o1
269} // namespace detail
270} // namespace pineforge
271
272#endif // PINEFORGE_DETAIL_O1_STORE_HPP
virtual Stats stats() const =0
virtual std::size_t write(Id, std::uint64_t off, const void *src, std::size_t n)=0
virtual bool join_active_tx()=0
virtual void note_cleanup_skipped(CleanupSkip reason) noexcept=0
virtual std::unique_ptr< Tx > begin()=0
static constexpr std::size_t max_caller_handles() noexcept
Definition o1_store.hpp:192
virtual std::uint64_t size(Id) const =0
virtual std::unique_ptr< Tx > begin_cleanup()=0
virtual std::size_t read(Id, std::uint64_t off, void *dst, std::size_t n) const =0
virtual void abandon_joined_tx() noexcept=0
virtual bool alive(Id) const =0
virtual bool resize(Id, std::uint64_t bytes)=0
static std::unique_ptr< Store > open(const Shape &, ScratchBacking &)
virtual Id create(std::uint64_t bytes, std::uint64_t align)=0
virtual std::uint64_t rollback_epoch() const noexcept=0
virtual void release(Id)=0
TxScope & operator=(const TxScope &)=delete
TxScope(const TxScope &)=delete
virtual void rollback()=0
virtual void commit()=0
constexpr std::size_t kStoreReadCacheBytes
Definition o1_store.hpp:74
constexpr std::uint32_t kMaxResidentPages
Definition o1_store.hpp:58
std::uint64_t Id
Definition o1_store.hpp:49
constexpr std::uint32_t kMaxChunkBytes
Definition o1_store.hpp:57
constexpr Id kNullId
Definition o1_store.hpp:50
constexpr std::size_t kStoreReadCacheSlots
Definition o1_store.hpp:73
constexpr std::size_t kMaxTxHandles
Definition o1_store.hpp:68
constexpr std::uint64_t kMaxLiveObjectsCap
Definition o1_store.hpp:61
std::uint64_t cleanup_skipped_rollback_only
Definition o1_store.hpp:119