PropertyDependencies.kt
src/jvmMain/kotlin/afield/document/properties/PropertyDependencies.kt · 512 lines
package afield.document.properties
Records which boxes read values held by other boxes, per document, and saves that record as
dependencies.edn beside metadata.edn.
PropertyDependencies below describes what is recorded and when it is written; the tests are
PropertyDependenciesTest.
package and imports — 44 lines, from line 11
import afield.app.diagnostics.named
import afield.app.server.documents
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.workspace.metadata
import afield.runtime.execution.plus
import afield.runtime.kotlin.exported
import afield.runtime.lifecycle.ReactiveRuns
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with
import afield.ui.output.history
import afield.ui.output.md
import afield.app.protocol.DependencyFlow
import afield.app.protocol.DependencyGraph
import afield.app.protocol.DependencyMiss
import afield.app.protocol.computeIfAbsent
import afield.app.protocol.payload
import afield.app.server.ReadOnlyDocuments
import afield.app.server.SessionInformation
import afield.audio.synth.*
import afield.data.table.get
import afield.document.context.ContextResolution
import afield.document.graph.Box
import afield.document.graph.HasHierarchy
import afield.document.graph.METAROOT_ID
import afield.document.graph.box
import afield.document.persistence.Edn
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.Runloop
import afield.runtime.lifecycle.map
import afield.ui.output.templates.filter
import afield.app.diagnostics.logging.lprintln
import afield.data.collections.isTrue
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
object PropertyDependencies
Which boxes read values that other boxes hold — recorded as box code runs, kept per document, and
saved beside metadata.edn as dependencies.edn.
Every inherited read goes through ContextResolution.search (prop / rootProp's Inheritance,
@exported's InheritanceFromContext, fromContext and fromContextWithNameOrNull, FieldML's
prop), so that walk is the one place a read is recorded (recordResolution). The inherited
delegates' setters record the other half, a box writing a value into a different box
(recordWrite) — that is how rootProp works: root.speed = 3.0 in one box, root.speed read in
another, and the producer is the writer, not the root that holds it.
What an entry is
One Edge per (reader, supplier, name, mechanism, access), with a count and first/last-seen
times, rather than a log of every read: a box drawing every frame reads renderer sixty times a
second, and the question the record answers is "who depends on whom", not "how often".
- reader — the box whose code made the access:
Box.context(the executing box) when there is one, else the box the walk started from. They differ fornamed("other").speed. - supplier — for a read, the box that held the value; for a write, the box written into; for a miss, empty.
- A read of a box's own value is not a dependency and is not recorded, nor is a write into one's own box.
A miss is a non-explain walk that found nothing — MissingInheritedPropertyException, or a
nullable prop that came back null. fromContextWithNameOrNull misses are not recorded: that
call is an optional probe, and library code uses it as one. Misses are what upstream auto-execution needs: on startup a
box misses speed, and the remembered edges say which box supplied speed to it last time
(flows, DependencyMiss.rememberedSuppliers).
Persistence
In memory per document path (SessionInformation.filename); loaded from dependencies.edn on
first use, written by the autosave loop (flush) and on shutdown. A new edge is written at the
next autosave tick; counts and times that only moved are written at most every
COUNT_FLUSH_INTERVAL_MS. Read-only and scratch documents record in memory and never write.
Off with -Dafield.dependencyTelemetry=false or AFIELD_DEPENDENCY_TELEMETRY=off.
dev_docs/PROPERTY_DEPENDENCIES.md has the design and the road to Marimo-style re-execution.
object PropertyDependencies {
const val FILE_NAME = "dependencies.edn"
const val COUNT_FLUSH_INTERVAL_MS = 60_000L
val clock = AtomicLong()
Orders accesses against each other and against propDown changes (ReactiveRuns): every
recorded access and every change takes the next value. Milliseconds are too coarse for that.
val sessionStartedAt: Long = System.currentTimeMillis()
When this process started recording: an edge last seen before this was remembered from disk.
var enabled: Boolean
@Volatile
var enabled: Boolean = System.getProperty("afield.dependencyTelemetry")?.lowercase() !in setOf("false", "off", "0") &&
System.getenv("AFIELD_DEPENDENCY_TELEMETRY")?.lowercase() !in setOf("false", "off", "0")
enum class Access { READ, MISS, WRITE }
data class Key
data class Key(
val reader: String,
val supplier: String,
val name: String,
val mechanism: String,
val access: Access,
)
class Edge
class Edge(
val key: Key,
@Volatile var firstSeen: Long,
@Volatile var lastSeen: Long,
initialCount: Long,
/** `PROPERTY` / `VARIABLE` / `SELF` for a read; null otherwise. */
@Volatile
var matchKind: String?,
/** The property key or variable name that matched — differs from [Key.name] for a by-type query. */
@Volatile
var matchedName: String?,
) {
val count = AtomicLong(initialCount)
val sessionCount = AtomicLong(0)
Accesses since this process started.
var lastSeq
clock at the last access; 0 for an edge only remembered from disk. Not persisted.
@Volatile
var lastSeq = 0L
var historical
The reader has finished a full run since this access without repeating it (retireReadsBefore): kept to explain what happened, but it no longer reruns the reader. The next access revives it.
@Volatile
var historical = false
}
class Store(val documentPath: String)
val edges = ConcurrentHashMap<Key, Edge>()
var structuralChange
A new edge since the last write: write at the next autosave tick.
@Volatile
var structuralChange = false
var countChange
Counts or times moved since the last write.
@Volatile
var countChange = false
var lastWrite
@Volatile
var lastWrite = 0L
fun clear()
fun clear() {
edges.clear()
structuralChange = true
}
}
private val stores = ConcurrentHashMap<String, Store>()
fun storeFor(documentPath: String): Store
The record for the document at documentPath, loading dependencies.edn on first use.
fun storeFor(documentPath: String): Store = stores.computeIfAbsent(documentPath) { path ->
Store(path).also { load(it) }
}
fun storeFor(box: Box): Store?
The record for box's document, or null for a box that is in no document.
fun storeFor(box: Box): Store? {
if (!box.isInsideInitialized()) return null
return storeFor(box.inside.filename)
}
internal fun recordResolution
Record the outcome of one non-explain walk. Called by ContextResolution.search; cheap on
the hit path (a thread-local read, one map lookup, one increment).
internal fun recordResolution(
asking: Box,
resolution: ContextResolution.Resolution?,
query: ContextResolution.Query,
mechanism: ContextResolution.Mechanism,
) {
if (!enabled) return
val reader = Box.context.get() ?: asking
if (resolution == null) {
// `fromContextWithNameOrNull` is an optional probe by design, and library code uses it
// that way (buffer and acceleration-structure lookups): its misses are not holes.
if (mechanism != ContextResolution.Mechanism.FROM_CONTEXT_NAMED) {
record(reader, "", query.description, mechanism.name, Access.MISS, null, null)
}
return
}
val supplier = resolution.supplier
if (supplier === reader) return
record(reader, supplier.id, query.description, mechanism.name, Access.READ, resolution.matchKind.name, resolution.matchedName)
}
fun recordNamedRead(reader: Box, supplier: Box, name: String)
Record that reader found name in supplier's properties by name, as a shader's sampler
binding does (Box.findWithNameOrNull). A propDown write to it then reruns reader.
fun recordNamedRead(reader: Box, supplier: Box, name: String) {
if (!enabled || supplier === reader) return
record(reader, supplier.id, name, ContextResolution.Mechanism.FROM_CONTEXT_NAMED.name, Access.READ,
ContextResolution.MatchKind.PROPERTY.name, name)
}
fun recordWrite(holder: HasHierarchy, name: String, mechanism: ContextResolution.Mechanism)
Record that the executing box wrote name into holder through an inherited delegate. A
write into the executing box itself (local shadowing) is not a dependency and is skipped.
body — 42 lines, from line 223
fun recordWrite(holder: HasHierarchy, name: String, mechanism: ContextResolution.Mechanism) {
if (!enabled) return
if (holder !is Box) return
val writer = Box.context.get() ?: return
if (writer === holder) return
record(writer, holder.id, name, mechanism.name, Access.WRITE, null, null)
}
private fun record(
reader: Box,
supplier: String,
name: String,
mechanism: String,
access: Access,
matchKind: String?,
matchedName: String?,
) {
val store = storeFor(reader) ?: return
val key = Key(reader.id, supplier, name, mechanism, access)
val now = System.currentTimeMillis()
val existing = store.edges[key]
if (existing != null) {
existing.count.incrementAndGet()
existing.sessionCount.incrementAndGet()
existing.lastSeen = now
existing.lastSeq = clock.incrementAndGet()
if (matchKind != null) existing.matchKind = matchKind
if (matchedName != null) existing.matchedName = matchedName
if (existing.historical) {
existing.historical = false
store.structuralChange = true
}
store.countChange = true
return
}
val edge = store.edges.computeIfAbsent(key) { Edge(it, now, now, 0, matchKind, matchedName) }
edge.count.incrementAndGet()
edge.sessionCount.incrementAndGet()
edge.lastSeen = now
edge.lastSeq = clock.incrementAndGet()
store.structuralChange = true
}
fun retireReadsBefore(box: Box, runStartSeq: Long)
box has just finished a full run (not a fragment) that began at runStartSeq: every access it
made before then and did not repeat during the run is now historical. A completed run replaces
the box's live dependencies; reads its frames {} loops and callbacks make later revive theirs.
fun retireReadsBefore(box: Box, runStartSeq: Long) {
val store = storeFor(box) ?: return
var changed = false
for (e in store.edges.values) {
if (e.key.reader != box.id || e.historical || e.lastSeq >= runStartSeq) continue
e.historical = true
changed = true
}
if (changed) store.structuralChange = true
}
Derived views
data class Flow
One producer → consumer arrow: from's value reached to's code, under any of names.
Derived from the edges: a read from a box is a flow from it; a read from a box that some
other box wrote into (the rootProp pattern) is a flow from the writer. The holder still
counts when it is an ordinary box; the document root is never a producer.
data class Flow(
val from: String,
val to: String,
val names: List<String>,
val count: Long,
val sessionCount: Long,
val lastSeen: Long,
/** Every edge behind this arrow is historical. */
val historical: Boolean = false,
)
fun flows(store: Store, rootId: String? = null, includeHistorical: Boolean = false): List<Flow>
Every producer → consumer flow in store, merged per pair. Historical edges are left out
unless includeHistorical; then an arrow made only of them is marked Flow.historical.
fun flows(store: Store, rootId: String? = null, includeHistorical: Boolean = false): List<Flow> {
val edges = store.edges.values.filter { includeHistorical || !it.historical }
// (holder, name) -> writers
val writers = HashMap<Pair<String, String>, MutableList<Edge>>()
for (e in edges) if (e.key.access == Access.WRITE) {
writers.getOrPut(e.key.supplier to e.key.name) { ArrayList(1) } += e
}
class Acc(var count: Long = 0, var session: Long = 0, var lastSeen: Long = 0, val names: LinkedHashSet<String> = LinkedHashSet(), var live: Boolean = false)
val merged = LinkedHashMap<Pair<String, String>, Acc>()
fun add(from: String, to: String, name: String, e: Edge) {
if (from == to || from.isEmpty()) return
val acc = merged.getOrPut(from to to) { Acc() }
acc.count += e.count.get()
acc.session += e.sessionCount.get()
acc.lastSeen = maxOf(acc.lastSeen, e.lastSeen)
acc.names += name
if (!e.historical) acc.live = true
}
for (e in edges) {
if (e.key.access != Access.READ) continue
val holder = e.key.supplier
val name = displayName(e.key.name)
if (holder != rootId && holder != METAROOT_ID) add(holder, e.key.reader, name, e)
writers[holder to e.key.name]?.forEach { w -> add(w.key.reader, e.key.reader, name, e) }
}
return merged.map { (k, a) -> Flow(k.first, k.second, a.names.toList(), a.count, a.session, a.lastSeen, historical = !a.live) }
}
fun upstreamOf(box: Box): List<String>
The boxes that supplied box's inherited values, as far as this document has seen.
fun upstreamOf(box: Box): List<String> {
val store = storeFor(box) ?: return emptyList()
val rootId = box.inside.root.id
return flows(store, rootId).filter { it.to == box.id }.map { it.from }.distinct()
}
fun rememberedSuppliers(store: Store, reader: String, name: String, rootId: String?): List<String>
The boxes that supplied name to reader on any earlier read — what an upstream auto-run would
execute when reader misses name. Suppliers of live reads come first, historical ones last.
fun rememberedSuppliers(store: Store, reader: String, name: String, rootId: String?): List<String> {
val out = LinkedHashSet<String>()
for (e in store.edges.values.sortedBy { it.historical }) {
if (e.key.access != Access.READ || e.key.reader != reader || e.key.name != name) continue
val holder = e.key.supplier
if (holder != rootId && holder != METAROOT_ID) out += holder
for (w in store.edges.values) {
if (w.key.access == Access.WRITE && w.key.supplier == holder && w.key.name == name && w.key.reader != reader) out += w.key.reader
}
}
return out.toList()
}
fun displayName(query: String): String
afield.graphics.vulkan.Renderer → Renderer; a property name stays as it is.
fun displayName(query: String): String =
if (query.contains('.') && query.none { it.isWhitespace() }) query.substringAfterLast('.').substringAfterLast('$') else query
fun graph(info: SessionInformation): DependencyGraph
The wire form the dependency layer draws.
fun graph(info: SessionInformation): DependencyGraph {
val store = storeFor(info.filename)
val rootId = info.root.id
val flows = flows(store, rootId, includeHistorical = true).map {
DependencyFlow(it.from, it.to, it.names, it.count, it.sessionCount, it.lastSeen, historical = it.historical)
}
// A miss that a later read of the same name answered is history, not a hole.
val lastRead = HashMap<Pair<String, String>, Long>()
for (e in store.edges.values) if (e.key.access == Access.READ) {
lastRead.merge(e.key.reader to e.key.name, e.lastSeen, ::maxOf)
}
val misses = store.edges.values.filter {
it.key.access == Access.MISS && !it.historical && (lastRead[it.key.reader to it.key.name] ?: -1L) < it.lastSeen
}.map {
DependencyMiss(
box = it.key.reader,
name = displayName(it.key.name),
count = it.count.get(),
sessionCount = it.sessionCount.get(),
lastSeen = it.lastSeen,
rememberedSuppliers = rememberedSuppliers(store, it.key.reader, it.key.name, rootId),
)
}
return DependencyGraph(
now = System.currentTimeMillis(),
sessionStartedAt = sessionStartedAt,
enabled = enabled,
edgeCount = store.edges.size,
flows = flows,
misses = misses,
)
}
Persistence
fun flush(info: SessionInformation, force: Boolean = false)
Write info's record if it changed: at once for a new edge, otherwise at most every
COUNT_FLUSH_INTERVAL_MS. Called from the autosave loop; force (shutdown) writes any change,
on the calling thread.
fun flush(info: SessionInformation, force: Boolean = false) {
if (!info.writesToDisk || info.loading) return
val store = stores[info.filename] ?: return
val now = System.currentTimeMillis()
val due = store.structuralChange ||
(store.countChange && (force || now - store.lastWrite >= COUNT_FLUSH_INTERVAL_MS))
if (!due) return
if (ReadOnlyDocuments.blocksWritesTo(info.filename)) return
prune(store, info)
store.structuralChange = false
store.countChange = false
store.lastWrite = now
val work = Runnable {
try {
synchronized(store) { write(store, File(info.filename)) }
} catch (t: Throwable) {
store.structuralChange = true
lprintln("warning,context", "could not write ${FILE_NAME} for ${info.filename}: ${t.message}")
}
}
// Off the main runloop, which is where the autosave loop calls from; inline on shutdown,
// when the pool is being shut down by its own hook.
if (force) work.run() else Runloop.miscPool.execute(work)
}
Forget edges whose reader has left the document, or whose supplier was deleted from it.
private fun prune(store: Store, info: SessionInformation) {
fun gone(id: String): Boolean {
if (id.isEmpty() || id == info.root.id) return false
val box = info.boxes[id] ?: return true
return box.getPropertyRaw("_tombstone").isTrue()
}
store.edges.keys.removeIf { k ->
gone(k.reader) || (info.boxes[k.supplier]?.getPropertyRaw("_tombstone").isTrue())
}
}
internal fun write(store: Store, documentDir: File)
body — 59 lines, from line 444
internal fun write(store: Store, documentDir: File) {
if (!documentDir.isDirectory) return
val edges = store.edges.values.sortedWith(compareBy({ it.key.reader }, { it.key.supplier }, { it.key.name }, { it.key.access }))
val payload = linkedMapOf<String, Any?>(
"version" to 1,
"edges" to edges.map { e ->
linkedMapOf<String, Any?>(
"reader" to e.key.reader,
"supplier" to e.key.supplier,
"name" to e.key.name,
"mechanism" to e.key.mechanism,
"access" to e.key.access.name,
"count" to e.count.get(),
"firstSeen" to e.firstSeen,
"lastSeen" to e.lastSeen,
"matchKind" to e.matchKind,
"matchedName" to e.matchedName,
"historical" to (if (e.historical) true else null),
).filterValues { it != null }
},
)
val target = File(documentDir, FILE_NAME)
val tmp = File(documentDir, "$FILE_NAME.tmp")
tmp.writeText(Edn.write(payload))
if (!tmp.renameTo(target)) {
target.delete()
tmp.renameTo(target)
}
}
private fun load(store: Store) {
val file = File(store.documentPath, FILE_NAME)
if (!file.isFile) return
try {
val root = Edn.read(file.readText()) as? Map<*, *> ?: return
val edges = root["edges"] as? List<*> ?: return
for (raw in edges) {
val m = raw as? Map<*, *> ?: continue
val access = runCatching { Access.valueOf(m["access"] as String) }.getOrNull() ?: continue
val key = Key(
reader = m["reader"] as? String ?: continue,
supplier = m["supplier"] as? String ?: "",
name = m["name"] as? String ?: continue,
mechanism = m["mechanism"] as? String ?: "",
access = access,
)
store.edges[key] = Edge(
key,
firstSeen = (m["firstSeen"] as? Number)?.toLong() ?: 0L,
lastSeen = (m["lastSeen"] as? Number)?.toLong() ?: 0L,
initialCount = (m["count"] as? Number)?.toLong() ?: 0L,
matchKind = m["matchKind"] as? String,
matchedName = m["matchedName"] as? String,
).also { it.historical = m["historical"] == true }
}
} catch (t: Throwable) {
lprintln("warning,context", "could not read ${file.path}: ${t.message}")
}
}
fun forget(documentPath: String)
Drop the in-memory record for documentPath (tests, and a document closing).
fun forget(documentPath: String) {
stores.remove(documentPath)
}
internal fun holds(documentPath: String): Boolean = stores.containsKey(documentPath)
Whether an in-memory record for documentPath is held (tests of document close).