API Reference
Everything exported by DynamicObjects. For usage and worked examples see the manual.
The struct macro
DynamicObjects.@dynamicstruct Macro
@dynamicstruct [docstring] struct Name
field # fixed field (constructor argument)
prop = expr # lazily computed property
@cached prop = expr # lazily computed + disk-cached property
prop(idx) = expr # indexable property (cached per args; `@fresh` to bypass)
prop(args...; kw...) = expr # indexable property (cached per args; `@fresh` to bypass)
@cached prop(idx) = expr # indexable + disk-cached property (cached per index)
endDefine a struct whose fixed fields are set at construction time and whose derived properties are computed lazily on first access and then stored in an in-memory cache.
Derived properties may reference any other field or property by name; the reference is automatically rewritten to __self__.<name>. Order of definition does not matter — cycles will result in a stack overflow at runtime.
The in-memory cache is always a ThreadsafeDict — safe to access from multiple tasks simultaneously; duplicate work is avoided by sharing in-flight Tasks.
Properties marked @cached are additionally persisted to disk under __self__.__cache_path__ (which itself defaults to joinpath(__self__.__cache_base__, __self__.__hash__)).
Keyword arguments passed to the constructor pre-populate the cache, so they act as overrides for any computed property.
Examples
using DynamicObjects
@dynamicstruct struct Point
x::Float64
y::Float64
r = sqrt(x^2 + y^2)
theta = atan(y, x)
end
p = Point(3.0, 4.0)
p.r # 5.0
p.theta # atan(4, 3)# Disk-cached expensive computation.
# cache_path defaults to joinpath("cache", hash(n)), so two Experiment(n)
# instances with the same n share the same cache directory.
@dynamicstruct struct Experiment
n::Int
@cached result = sum(rand(n)) # computed once, then loaded from disk
end
e = Experiment(1_000_000)
e.result # computed on first access, cached to disk
e2 = Experiment(1_000_000)
e2.result # loaded from disk (same n → same hash → same cache path)# Indexed properties. `obj.prop(args)` caches by default; `@fresh obj.prop(args)` recomputes.
# Properties reference each other by bare name (auto-rewritten to __self__.<name>).
@dynamicstruct struct DataSet
items = ["apple", "banana", "cherry"]
matches(query) = filter(x -> occursin(query, x), items) # call: cached per query
top(query; n=1) = first(matches(query), n) # call with kwargs
end
ds = DataSet()
ds.matches("an") # ["banana"] — cached in the per-property dict (keyed by args)
@fresh ds.matches("an") # ["banana"] — recomputed fresh, bypassing the cache
fresh(ds.matches, "an") # ["banana"] — explicit uncached call (outside a @dynamicstruct body)
ds.top("a"; n=2) # ["apple", "banana"] — kwargs supportedAsync progress with __status__
Indexed properties spawn background Tasks, and progress is wired into them automatically: __status__ defaults to a Treebars.initialize_progress!(:state; description="") root, and the default __substatus__ hangs a child node under it per property compute. Declare __status__ only to label the root (an empty description makes it a structural node that renders nothing and hoists its children), or set it to nothing to switch progress off.
Like any x = y in a struct body, that declaration is an overrideable default: a constructor kwarg seeds the cache and wins. @include kid = Child() therefore mounts the child under the parent's tree whatever Child declares; pass @include kid = Child(; __status__ = nothing) to silence that subtree instead.
@dynamicstruct struct MyApp
__status__ = initialize_progress!(:state; description="MyApp") # optional: labels the root
results(key) = expensive_computation(__status__) # __status__ is the substatus
end
app = MyApp()
# Non-blocking access with progress:
fetchindex(app.results, key) do rv, status
rv isa Pending ? render_progress(status) : render_result(rv)
end__substatus__ is called before each compute begins. name is the property symbol, args/kwargs are the indices. The returned object is stored in ThreadsafeDict.status (accessible via getstatus) and passed to the computation body as the local __status__.
__substatus__ fires on every generated-property compute — indexed (memoize!) and bare scalar (_bare_substatus_f) alike. It is skipped for dunder properties (__hash__, __status__, …) and for fixed struct fields, which have no body.
An undocumented property gets description="", i.e. a structural node that renders nothing and hoists its children; add a docstring to make it a labelled row in the tree.
In-struct property markers
These are not real macros — they are pattern-matched by @dynamicstruct inside a struct body. Don't rely on them in arbitrary positions.
| Marker | Effect |
|---|---|
@cached prop = expr | Persist to disk under cache_path. Per-key for indexed properties. |
@cached v"N" prop = expr | Versioned disk cache; bumping N invalidates files without changing inputs. |
@persist prop = expr | Write the in-memory value back to disk on demand (see @persist). |
Cache inspection
Real macros — usable inside and outside @dynamicstruct bodies. Inside a body, drop the object prefix and use the bare property name.
DynamicObjects.@cache_status Macro
@cache_status o.prop
@cache_status o.prop(indices...)Return the disk-cache status of a @cached property as a Symbol:
:unstarted— no cache file exists yet.:started— an empty placeholder file exists (previous run may have crashed).:ready— a complete cache file exists and can be deserialized.
Can be used both outside and inside a @dynamicstruct body. Inside a struct definition, omit the object prefix — just use the property name (with parens for indexed properties).
# Outside the struct:
@cache_status e.result # :unstarted (before first access)
e.result
@cache_status e.result # :ready
@cache_status e.ci(2) # for indexed properties — call syntax
# Inside the struct body:
@dynamicstruct struct App
@cached result(key) = expensive(key)
status(key) = @cache_status result(key) # :unstarted, :started, or :ready
endThe legacy bracket form (@cache_status o.prop[indices...]) still works for backward compatibility but is discouraged in new code — prefer call syntax, which mirrors the way the property is invoked.
DynamicObjects.@is_cached Macro
@is_cached o.prop
@is_cached o.prop(indices...)Return true if the disk cache for o.prop (or o.prop(indices...)) is :ready, i.e. the cached value can be loaded from disk without recomputation.
Can be used both outside and inside a @dynamicstruct body. Inside a struct definition, omit the object prefix — just use the property name (with parens for indexed properties).
# Outside the struct:
@is_cached e.result # false before first access, true afterwards
# Inside the struct body:
@dynamicstruct struct App
@cached result(key) = expensive(key)
summary(key) = if @is_cached result(key)
"cached: $(@memo! result(key))"
else
"not yet computed"
end
endThe legacy bracket form (@is_cached o.prop[indices...]) still works for backward compatibility but is discouraged in new code.
DynamicObjects.@cache_path Macro
@cache_path o.prop
@cache_path o.prop(indices...)Return the file path where the disk-cached value of o.prop (or o.prop(indices...)) is (or would be) stored.
@cache_path e.result # e.g. "cache/<hash>/result.sjl"
@cache_path e.ci(2) # "cache/<hash>/ci_2.sjl"The legacy bracket form is still accepted but discouraged.
sourceDynamicObjects.@clear_cache! Macro
@clear_cache! o.prop
@clear_cache! o.prop(indices...)Clear the disk cache (and in-memory cache) for a @cached property.
Without indices, clears all cached entries for the property (both the in-memory value and all .sjl files for that property on disk). With indices, clears only the specific entry.
@clear_cache! e.result # clear all cached entries for `result`
@clear_cache! e.ci(3) # clear only the (3,) entryThe legacy bracket form is still accepted but discouraged.
sourceDynamicObjects.@persist Macro
@persist o.prop
@persist o.prop(indices...)Write the in-memory value of o.prop (or the indexed entry o.prop(indices...)) back to its disk cache. Use after mutating a value in place when the property was declared with @cached and the on-disk copy is now stale relative to the in-memory copy.
The legacy bracket form (@persist o.prop[indices...]) still works but is discouraged in new code — prefer call syntax.
Functions
DynamicObjects.remake Function
remake(obj; kwargs...)Create a new instance of the same @dynamicstruct type as obj, copying all fixed fields from obj and overriding any specified via keyword arguments.
Keyword arguments that correspond to fixed fields replace those field values in the new instance. Any remaining keyword arguments are forwarded to the constructor as cache pre-population overrides.
Because a @dynamicstruct is a pure function of its fixed fields and cache overrides, any already memoized property of obj whose transitive dependencies — fixed fields and rhs-declared properties alike — are all unchanged is carried over to the new instance instead of being recomputed — the per-type carry set is baked from the dependson graph at macro-expansion, so the decision costs nothing at runtime. Overriding an rhs-declared property therefore recomputes its dependents; properties whose dependency set is unprovable (an opaque reach of object state) recompute on any change. Impure properties (reading rand, the clock, or external mutable state) violate this contract and must not be relied on across remake.
Example
@dynamicstruct struct Config
n::Int
scale::Float64
base = sum(1:n) # depends only on n
result = scale * base # depends on scale (and, transitively, n)
end
c = Config(100, 2.0); c.result # memoizes base + result
c2 = remake(c; scale=3.0) # n unchanged → `base` CARRIED over; `result` recomputed
c3 = remake(c; n=200) # n changed → both base & result recomputed
c4 = remake(c; result=0.0) # result pre-set to 0.0 (explicit override wins)DynamicObjects.remount Function
remount(obj; context...)Return an immutable, same-type view of obj with fresh context properties while retaining the cache identity of unrelated model work. This is the request/job counterpart to remake: remake constructs a new value and copies only settled dependency-safe results, whereas remount keeps the retained cache's settled values, in-flight latches/Pending handles, mmap values, version identity, and indexed subcaches for every property proven independent of the rebound context.
The keyword names are existing non-fixed properties such as __parent__, __req__, or __prefix__. They, every transitive dependent in meta(T), every opaque self-dependent property, progress state, and nested child are routed to a fresh mount-local cache. Each mounted IndexableProperty wrapper is recreated with the mounted owner; its per-argument cache is shared only when the property is context-independent. Context-dependent @cached/@mmap properties bypass their intrinsic disk entry because the rebound context is intentionally not part of the retained disk identity.
Fixed fields, @versioned properties, and cache/hash/path dunders are rejected: changing any of those means the object identity changed, so use remake.
Example
routed = ModelGraph(...)
request_a = remount(routed; __req__=req_a, __parent__=parent_a, __prefix__="/a")
request_b = remount(routed; __req__=req_b, __parent__=parent_b, __prefix__="/b")DynamicObjects.fetchindex Function
fetchindex(fetch, ip, indices...; kwargs...)Call memoize!(ip, indices...; kwargs...) with a custom fetch function.
For IndexableProperty backed by a ThreadsafeDict, the fetch callback receives (rv, status) where rv is a Pending handle (still computing) or the computed result (done), and status is the substatus object (from __substatus__) or nothing. fetch(::Pending) blocks for the value (rethrowing if the compute failed).
Pass force=true to unconditionally recompute: runs invalidate! on the entry first (in-memory + on-disk dropped), so the access recomputes from scratch. See invalidate! for the in-flight semantics.
Example
fetchindex(app.results, key) do rv, status
if rv isa Pending
# still computing — status is the progress node
render_progress(status)
else
# done — render result
render(rv)
end
endDynamicObjects.fetchindex! Function
fetchindex!(callback, ip, indices...; fetch=Base.fetch, kwargs...)In-place variant of fetchindex. When callback is nothing, falls through to a plain memoize!(ip, indices...; fetch, kwargs...) — useful for sites that opt out of the two-phase fetch dance without changing call shape.
DynamicObjects.fetchproperty Function
fetchproperty(fetch, o, name::Symbol)Like fetchindex but for bare (non-indexed) properties. Triggers computation via the PropertyCache and calls fetch(rv, status) where rv is a Pending handle (the compute is still in flight — fetch it to block for the value) or the already-computed result, and status is the substatus object or nothing.
For Dict-backed caches (serial), falls through to getproperty (synchronous, no status). The two-phase dance only applies to ThreadsafeDict-backed caches.
DynamicObjects.fetchproperty! Function
fetchproperty!(callback, o, name::Symbol)In-place variant of fetchproperty. When callback is nothing, falls through to getproperty(o, name).
DynamicObjects.getstatus Function
getstatus(ip::IndexableProperty, indices...; kwargs...)Return the status object associated with an in-flight computation for the given key, or nothing if no status exists (computation not started, already finished, or no __substatus__ defined).
Only meaningful for IndexableProperty backed by a ThreadsafeDict.
DynamicObjects.Pending Type
PendingCheap, non-Task handle a poller (fetchindex / fetchproperty / get!(…; fetch=identity)) gets back while a value is still being computed. It points at where the value will land — a Slot (bare props) or the backing (cache, key) — plus the in-flight latch, so a caller can fetch(p) to BLOCK for the value (rethrowing if the compute failed) or ignore it and poll again later. Replaces the former "rv is a Task while in-flight" poll contract: callers now branch on rv isa Pending (still computing) vs. the value (done). The progress/status node stays optional and is never relied on for readiness.
Reflection and application declarations
DynamicObjects.property_descriptor Function
property_descriptor(T::Type, property::Symbol)Return a backward-safe, pure-data descriptor for one DynamicObjects property, or nothing when the type or property has no DO metadata. The result describes inputs/output, lifecycle semantics, option domains, and declared materialization without reading or computing an object property.
The descriptor's own domain is the domain of the property's value, and each entry of inputs carries the domain of that argument. Read the top-level one for a parameter a consumer sets — a fixed field, or an overrideable default like n_chain::Integer = 8, which is a computed property with no signature and therefore no inputs entry to hang a domain on. Every domain has one of three kinds:
:static— a finite domain the type proves (Bool, or anEnum), with the values inoptions.:declared— an@optionsdeclaration governs this parameter name; seeoption_declarations.domain.declarationis the record (declared expression, itsdependencies, andstatic);optionsis empty because reflection reports the declaration without running it. Callproperty_options(o, name)for the domain's actual value.:unrestricted— no domain is known; the type is the only constraint.
DynamicObjects.property_descriptors Function
property_descriptors(T::Type)Return descriptors in declaration order. Duplicate property declarations are preserved so consumers can inspect multiple indexed signatures.
sourceDynamicObjects.type_descriptor Function
type_descriptor(T::Type)Type-level counterpart to property_descriptor: what a reflection consumer needs about the node as a whole, so "what is this thing called" has exactly one answer rather than one per consumer.
Returns (; type, name, description, options).
descriptionisT's own user-attached docstring, stripped, ornothingwhen none was attached. The auto-generated property-list docstring@dynamicstructinstalls as a?Tfallback is deliberately not reported: it is reference text, not a label.optionsisoption_declarations(T)— including any declaration whose parameter no input ofThappens to carry.
DynamicObjects.option_declarations Function
option_declarations(::Type{T}) -> Vector{Pair{Symbol,NamedTuple}}Every @options(<parameter>) = <domain expression> declaration in T's body, in declaration order. A parameter may be declared only once: duplicate declarations are rejected where the @dynamicstruct is defined so reflection and the generated __options__(::Val{parameter}) method can never disagree.
@options declares which values a parameter may take. It is the one thing the structural descriptors cannot infer: a finite domain is proved by the type only for Bool and Enum, and a domain that depends on another input cannot be proved at all. Both spellings are accepted — @options(x) = … (the marker binds its parenthesized argument, so it sits on the assignment's LHS) and @options x = ….
A declaration lowers to the dunder indexed property __options__(::Val{x}), so the domain is an ordinary lazily computed DO value. Nothing is evaluated at macro-expansion time and nothing is evaluated by reflection: the expression runs only when a consumer asks for the value, via property_options. That is what lets a domain be written in terms of application functions and data DO knows nothing about, and it is why the value memoizes and invalidates like every other property.
This function reports the declarations. Each entry's NamedTuple carries:
parameter::Symbol— the input this domain governs. It is matched by name against every input ofT: a fixed field, a positional or keyword argument of an indexed property, or a fixed field promoted into an operation's:contextinputs. One declaration therefore covers every operation that takes that name.expression— the declared expression, verbatim and unevaluated.expression_string::String—string(expression), for display.dependencies::Vector{Symbol}— the sibling properties/fields the expression reads, from the samedependsonwalk every property RHS gets.static::Bool—isempty(dependencies):truewhen the domain is fixed for the type,falsewhen it is context-dependent and a consumer must re-read it whenever one ofdependencieschanges.source::Symbol— which declaration form produced this record (:options).lnn— the declaration'sLineNumberNode, ornothing.
The same records reach consumers through the descriptor graph: an input governed by a declaration reports domain.kind === :declared with the record under domain.declaration (see property_descriptor). Reading them here is for whole-type inspection — e.g. a declaration whose parameter no attached input happens to carry.
DynamicObjects.has_option_declaration Function
has_option_declaration(T::Type, parameter::Symbol) -> BoolWhether T declares an @options domain for parameter. The cheap check a consumer makes before property_options — it reads declarations only and never touches an object.
DynamicObjects.property_options Function
property_options(o, parameter::Symbol)The declared domain for parameter on this object — the value of the @options expression, computed against o and memoized like any other property. Returns nothing when no declaration governs parameter.
This is the evaluating half of the option contract, and it needs an object for the same reason a dependent domain exists at all: @options(model) = models_for(study) is only answerable once study is fixed, and study is fixed by o. Reflection (option_declarations, property_descriptor) reports the declaration without running it; this runs it.
A domain is any value supporting in — a vector of nodes, a range, an interval, a type. What a consumer does with it (membership check, control choice, rejecting a submission made against a stale domain) is the consumer's; DO neither interprets the value nor caches a rendering of it.
DynamicObjects.option_domain Function
option_domain(values) -> OptionDomainAn @options domain whose values carry human labels. The labels travel with the values, in one declaration, so they can never disagree about how many options there are or what order they are in.
@dynamicstruct struct Gallery
study::Symbol
dose::Float64
@options(study) = option_domain([
:synthetic_depot_v1 => "Sparse depot PK",
:synthetic_depot_dense_v1 => "Dense depot PK",
])
@options(dose) = option_domain(d => "$(Int(d)) mg" for d in doses_for(study))
endEach element is normalized to the same option record static_domain produces — (; value, label, group, help, disabled) — from any of three spellings:
value => label, the usual case;a
NamedTuplewith avaluefield plus any oflabel,group,help,disabled(labeldefaults tostring(value)); ora bare value, which takes the default label.
Nothing else about @options changes. The declaration is still an ordinary lazily computed property, so a dependent domain works exactly as before — dose's labels are recomputed with dose's values whenever study changes. Membership is still on machine values: o.study in property_options(o, :study) is true for the same values a bare vector would have accepted, which is why a domain may be labelled without touching validation.
Labels are deliberately not on the descriptor's domain: an @options domain may depend on the object, so its labels are per-object too, and reflection never evaluates a declaration (option_declarations). domain.kind stays :declared with options empty; a renderer reads the labels from the evaluated value via property_options + option_records.
DynamicObjects.option_records Function
option_records(domain) -> Vector{NamedTuple} or nothingThe option records of an evaluated domain — one (; value, label, group, help, disabled) per value, in domain order. This is the one call a renderer makes; it does not care how the domain was spelled:
for option in option_records(property_options(object, :study))
radio(option.value; label=option.label, checked=(option.value == object.study))
endAn option_domain returns its declared labels. Any other finite collection — a vector, tuple, range, or Set — is normalized with default labels (string(value)), so an unlabelled @options declaration still renders. Returns nothing for a domain that is not a finite collection (a type, an interval): there is no list to render, and the consumer falls back to a free input.
DynamicObjects.declaration_metadata Function
declaration_metadata(T::Type) -> NamedTuple
declaration_metadata(T::Type, ::Val{property}) -> NamedTuple
declaration_metadata(T::Type, ::Val{property}, ::Val{declaration}) -> NamedTupleExtension hook for declaration-site metadata that belongs on a type or property node in declaration_graph. The default is an empty NamedTuple.
A declaration macro in another package can emit a method beside the declaration it expands, just as a docstring attaches information to a binding. The three-argument form distinguishes duplicate declarations with the same property name; by default it falls back to the two-argument property method. Return only pure data. DynamicObjects stores the result verbatim under node.metadata.extensions and does not interpret its keys.
DynamicObjects.declaration_graph Function
declaration_graph(root::Type; contributions=())
declaration_graph(object; contributions=())Build a deterministic, non-evaluating application declaration graph from DynamicObjects metadata. The object overload reflects typeof(object); it does not read the object. The result is pure Julia data:
(; schema="dynamicobjects.declaration-graph/v1", root, nodes, edges, metadata)
root is the stable ID of the root :type node. Every node has at least (;id::String, kind::Symbol, label::String, metadata, fragment::String) and every edge at least (;id::String, kind::Symbol, from::String, to::String, metadata, fragment::String). DynamicObjects emits :type, :property, and :domain nodes, connected only by explicit :contains, direct :depends_on, and :describes relations. Property metadata includes its owner type ID, semantic name, duplicate-declaration occurrence, normalized semantic descriptor, structured signature, declaration extensions, and full captured source AST, code, and file/line provenance.
Each contribution is a pure-data (;namespace::String, nodes, edges) fragment using the same envelopes. Base DO data comes first; fragments and the node/edge vectors inside each fragment keep caller order. The namespace is recorded as fragment for provenance but never rewrites IDs. IDs must be globally unique across all nodes and edges; collisions (including with DO IDs) and dangling endpoints are rejected after every fragment is present, so cross-fragment edges may target later fragments.
Use declaration_node_id to connect external nodes to a DO declaration without depending on the spelling of DO IDs. Runtime state is deliberately not part of this graph; see declaration_observations.
DynamicObjects.declaration_node_id Function
declaration_node_id(graph, T::Type) -> Union{String,Nothing}
declaration_node_id(graph, T::Type, property::Symbol; declaration=1)
-> Union{String,Nothing}Look up stable DO node IDs without depending on their encoded spelling. The property form selects the one-based occurrence among same-name declarations.
sourceDynamicObjects.materialization_observation Function
materialization_observation(object, property::Symbol, args...; kwargs...)Observe a property's current memory/disk/pending/progress state without computing that property. For disk-backed properties this resolves the ordinary DO cache path and inspects the file; it never creates or loads the target file.
sourceDynamicObjects.declaration_observations Function
declaration_observations(
object, graph=declaration_graph(typeof(object)); calls=())Return a separate, non-computing runtime-state overlay:
(;schema="dynamicobjects.declaration-observations/v1", graph=graph.root, observations)
Each observation is keyed by a stable property node ID as (;node, call, observation). Fixed and scalar properties owned by the root type are observed automatically. Indexed properties are omitted unless calls contains an explicit (;node, args=(), kwargs=(;)) record; this avoids inventing a call key. Nodes owned by nested types and contributed nodes are omitted unless a future composer binds an object for them.
The implementation only calls materialization_observation, which inspects fields, memory caches, pending state, and existing disk paths without calling getproperty, an indexed property, or a property body.
Cache maintenance
DynamicObjects.BackgroundCache Type
BackgroundCache{K,V}(build; ttl, unbuilt, maxsize=0, backoff_base=1.0, backoff_max=60.0, on_settle=(key, old, new) -> nothing)
BackgroundCache{K,V}(build_many; batch, ttl, unbuilt, maxsize=0, backoff_base=1.0, backoff_max=60.0, on_settle=(key, old, new) -> nothing)Keyed stale-while-revalidate cache.
Reads (c[key]) never block and never throw for cache reasons:
a fresh value (settled less than
ttlseconds ago) is returned as is;a stale value is returned immediately while a background refresh is kicked off — at most one refresh per key is ever in flight, so concurrent readers of a stale key share it;
a key with no settled value reads as
unbuilt(declared at construction — there is no default) while its first build runs in the background.
A refresh that throws keeps the previous value (or unbuilt), logs an error with a backtrace, and gates the key behind an exponential backoff (backoff_base * 2^(n-1) seconds after the nth consecutive failure, capped at backoff_max): reads during the gate serve the old value without kicking another refresh. A success clears the failure count.
With maxsize > 0 the cache holds at most maxsize settled entries; storing past the bound evicts settled keys with the oldest settle time first (an O(n) scan — this is a small policy cache, not a store). ttl=Inf never goes stale; ttl=0 revalidates on every idle read.
By default build(key)::V builds one key per background task. Pass batch=N (a positive integer) to refresh up to N wanted keys with one call instead: the first positional argument is then build_many(keys::Vector{K})::AbstractDict, one background drain task per cache feeds it batches of at most N keys, and each key in a batch settles (or fails with backoff) individually. A thrown batch call fails every key in that batch; a key missing from the returned dict — or holding a value that is not a V — fails that key alone while present keys still settle. Extra keys in the result are ignored.
After each successful store — once per settled key in batch mode too — the cache invokes on_settle(key, old, new) outside the lock, so a re-entrant read inside the callback observes the new value. old is the previously settled value, or unbuilt when the key had none (first build, post-invalidate!, or eviction). Failures never invoke it. It fires even when new == old — compare them to skip unchanged values. A throwing callback is logged with its key and backtrace and never breaks the refresh or the drain.
Timestamps use the monotonic clock. Treat returned values (including unbuilt) as read-only, and likewise the old/new values handed to on_settle. See also invalidate!.
DynamicObjects.entries Function
entries(ip::IndexableProperty)Return a vector of (; key, state, status, value) for all entries in a ThreadsafeDict-backed IndexableProperty. state is one of :running, :failed, or :done. value is the cached result (for :done), a Pending handle (for :running), or the captured exception (for :failed). status is the substatus object or nothing.
DynamicObjects.cached_entries Function
cached_entries(ip::IndexableProperty)Return a vector of (key, value) pairs for completed entries only.
DynamicObjects.clear_all_caches! Function
clear_all_caches!(obj)Clear all @cached properties on a @dynamicstruct instance — both in-memory and on disk. Equivalent to clear_mem_caches! + clear_disk_caches!.
DynamicObjects.clear_mem_caches! Function
clear_mem_caches!(obj)Clear all in-memory memoized property values on a @dynamicstruct instance, leaving disk caches (@cached files) untouched. Every derived property — including child DOs stored as values — will be recomputed on next access.
Constructor-passed inputs are NOT dropped: the kwargs the instance was created with (__parent__ wiring, inline-child index params, property overrides such as __status__ = nothing) are restored after the clear, so the instance keeps its meaning and only derived memos recompute. Without this a cleared parented child could never recompute: an undeclared __parent__ has no compute_property fallback (MethodError), and a declared one falls back to nothing. The same holds for a remount view, whose request context is restored into the view-local overlay (and the retained object's own inputs back into the shared cache).
Indexed-property entries are dropped from the per-argument subcaches themselves, not merely by dropping the top-level wrapper: a live remount request view (or any destructured wrapper handle) shares the retained subcache object, so dropping only the wrapper would leave a same-request render serving pre-clear values while later requests recompute. After this call every holder re-reads from scratch.
This is useful after hot-reloading code via Revise: property values computed by old method definitions stay memoized until the process restarts or this function is called.
In-flight semantics: as with invalidate!, a compute running at clear time still lands if its slot is empty when it finishes.
DynamicObjects.clear_disk_caches! Function
clear_disk_caches!(obj)Delete all on-disk cache files for @cached properties on a @dynamicstruct instance. In-memory values are left intact (they'll be stale until clear_mem_caches! is also called, or until the process restarts).
DynamicObjects.invalidate! Function
invalidate!(ip::IndexableProperty, indices...; kwargs...)Drop exactly one indexed-property cache entry — the in-memory value (plus any in-flight latch, recorded error, and status node) and the on-disk payload (@cached / @mmap / auto-materialized) — for the key (indices, (;kwargs...)). Sibling entries are untouched. The next access recomputes and re-caches.
This is the same primitive fetchindex(...; force=true) runs before recomputing, also reachable as @clear_cache! obj.prop(indices...).
In-flight semantics (there is no cancellation — DynamicObjects retains no compute handle): invalidating while a compute runs does not stop it. Its value still lands if the slot is empty when it finishes, so a bare invalidate! followed by a re-access can serve the pre-invalidation result; force=true spawns a second compute and the two race, first-published-wins. Blocking waiters migrate onto the new compute; a Pending handle fetched in the gap between the drop and the new compute's registration throws an ErrorException ("no value and no compute in flight").
invalidate!(c::BackgroundCache, key)Drop the settled value (and any failure/backoff record) for key; the next read serves the cache's unbuilt value and kicks a fresh background build. A refresh already in flight is not stopped — its value still lands.
Error handling
DynamicObjects.PropertyComputationError Type
PropertyComputationError <: ExceptionWraps an error that occurred during lazy property computation, adding context about which property failed (property name, type, indices/kwargs). The original exception and backtrace are stored in the cause field.
DynamicObjects.unwrap_error Function
unwrap_error(e)Recursively unwrap TaskFailedException, CompositeException, and PropertyComputationError wrappers to find the root cause exception.
Persistent collections
DynamicObjects.PersistentSet Type
PersistentSet(path)A thread-safe Set that persists to disk via Serialization. Loads existing data from path on construction, or starts empty if the file doesn't exist.
DynamicObjects.LazyPersistentDict Type
LazyPersistentDict{D<:AbstractDict}(path[, empty_data]; seed!)Threadsafe dict backed by Serialization.serialize/deserialize. The backing file path is resolved lazily via a callable path so the constructor itself is precompile-safe (no mkpath, no file I/O). The on-disk file is loaded on the first operation (double-checked under the lock), and the optional seed!(data) callback runs once after load if the dict is empty. Mutations persist to disk synchronously under the lock.
path may be an AbstractString (fixed path) or a 0-arg function returning a String. Pass an ordered backing dict (e.g. OrderedDict{K,V}()) to preserve insertion order.
Pluggable key tracking
For bounding on-disk caches when the full key set isn't known up front.
DynamicObjects.KeyTracker Type
KeyTrackerAbstract type for pluggable accessed-keys persistence strategies. Implement record!(tracker, key) and load_keys(tracker) for custom strategies.
Override key_tracker(o, ::Val{name}) on your object type to select a strategy.
DynamicObjects.SharedFileTracker Type
SharedFileTracker(path)Default strategy: all pods/processes share a single _keys.sjl file. Simple, but not safe for concurrent multi-process writes to NFS.
DynamicObjects.NoKeyTracker Type
NoKeyTracker()No-op strategy: never records or loads keys. Use when tracking is unwanted.
sourceDynamicObjects.key_tracker Function
key_tracker(o, ::Val{name}) -> KeyTrackerReturn the KeyTracker to use for property name on object o. Override this method on your type to change the tracking strategy.
# Example: disable accessed-key tracking for all properties on MyType
DynamicObjects.key_tracker(o::MyType, ::Val{name}) where {name} =
DynamicObjects.NoKeyTracker()DynamicObjects.record! Function
record!(tracker::KeyTracker, key)Record that key was accessed, persisting according to the tracker's strategy.
DynamicObjects.load_keys Function
load_keys(tracker::KeyTracker) -> SetLoad the full set of recorded keys according to the tracker's strategy.
sourceComposite mmap formats
An extension implements both DynamicObjects.save(Val(:mmap), path, value) and DynamicObjects.load(Val(:mmap), path, Type). Register a distinct leading magic with DynamicObjects.register_mmap_container!(magic::AbstractVector{UInt8}, loader) from the extension's __init__; loader(path) handles unannotated properties. DO supplies the writer with an unpublished sibling temporary path and renames it atomically after save succeeds. The extension must close and validate its whole envelope before returning, including its structural metadata and EOF.
Composite containers can reuse DOMM numeric blocks without copying DO's type registry, alignment or mmap implementation:
path = tempname()
blocks = open(path, "w") do io
write(io, "container-prefix")
map(([1.0, 2.0], fill(Int16(3)))) do array
start = position(io)
DynamicObjects.save(Val(:mmap), io, array)
(start, position(io))
end
end
arrays = open(path, "r") do io
map(blocks) do (start, stop)
seek(io, start)
DynamicObjects.load(Val(:mmap), io; end_offset=stop)
end
end
# arrays == ([1.0, 2.0], fill(Int16(3))); mappings survive the stream close.Stream overloads leave stream ownership with the caller. Loads require a read-only file stream and advance to the exact numeric block end. Supply the exclusive end_offset stored by the envelope to keep a corrupt leaf header from mapping the next leaf's bytes. Stream loading handles numeric DOMM blocks; the container registry remains the path-level format dispatcher.
DynamicObjects.save Function
DynamicObjects.save(Val(:mmap), io::IO, array::AbstractArray)Write a self-describing DOMM numeric array block at position(io) and leave io open at its end. Return the dense array written, without mutating the input. Supports the same numeric element types as the path overload, including empty and zero-dimensional arrays. Payload alignment is relative to the whole stream.
Composite mmap formats can record the starting and ending positions of each block and reuse load. This overload flushes and checks the block write; the enclosing format must also validate its complete file after closing its writer. When implementing save(Val(:mmap), path, container), DO supplies an unpublished temporary path and atomically publishes it only after save returns.
DynamicObjects.load Function
DynamicObjects.load(Val(:mmap), io::IOStream, annotation=nothing; end_offset=filesize(io))Read a DOMM numeric array block from position(io) and memory-map its payload. io must be a read-only file stream. Leave it open at the block's end; the array remains valid after the caller closes the stream. An optional array type checks the stored element type and number of dimensions; nothing reads them from the header. Only numeric DOMM blocks are supported by the stream overload.
Set end_offset to the exclusive end of an embedded block so a corrupt header cannot map bytes belonging to the next block. Header dimensions, byte-count overflow, file truncation and this enclosing boundary are checked before mapping.