- [What It Is](#what-it-is)
💡 Tip: A state fingerprint is TPipe’s isDirty() check for whole objects. Snapshot a pipe’s semantic state once, then detect later whether anything inside it changed — without the developer manually reporting the change.
Table of Contents
- What It Is
- When To Use It
- Tutorial
- Use Cases
- Behavioral Contract
- What Is Included, What Is Excluded
- Limitations
TPipe ships a deterministic state-fingerprint mechanism in the Util package (StateHasher.kt, package com.TTT.Util). It walks an object’s semantic state with kotlin-reflect, encodes the reachable graph into a canonical byte form, and digests it with SHA-256. The result is a 64-character hex string that changes — and only changes — when the object’s semantic state changes.
What It Is
stateHash(root, options)— hashes the state of any object graph.isDirty(root, lastHash, options)— compares a fresh hash against a stored one.Pipe.stateSnapshot()/Pipe.isDirtySince(lastSnapshot)— pipe-typed convenience extensions.
No sun.misc.Unsafe, no native code, no FFM heap addressing: the fingerprint is a pure-JVM reflection canonicalization, so it is stable across JDK upgrades without rework.
When To Use It
- A pipe’s configuration can be mutated at execution time. The pre-execute DITL hook is stateful by design (the modified state persists past the run, per the KDoc on
Pipe.preExecuteFunction), and TPipe auto-detects that drift and rebuilds the pipe viainit()— see Developer-in-the-Loop Functions. For any other code path that reconfigures a pipe, snapshot at build time withstateSnapshot()and compare afterward withisDirtySince(). - You want to invalidate caches keyed on a pipe’s structure.
- You want a regression guard that pins a pipe’s shape after arbitrary initialization code.
Tutorial
import com.TTT.Pipe.DummyPipe
import com.TTT.Util.stateSnapshot
import com.TTT.Util.isDirtySince
val pipeline = DummyPipe()
.setPipeName("report-pipeline")
.setConcurrency(2)
val baselineHash = pipeline.stateSnapshot()
// Later, after some DITL hook or operator code may have reconfigured the pipeline:
if(pipeline.isDirtySince(baselineHash))
{
println("Pipeline config drifted since snapshot — rebuild before re-running")
}
For generic (non-pipe) objects, use the top-level functions directly:
import com.TTT.Util.stateHash
import com.TTT.Util.isDirty
val config = MySettings()
val beforeHash = stateHash(config)
config.reload() // may or may not change anything
if(isDirty(config, beforeHash))
{
// some included field moved — act on it
}
Use Cases
- Drift detection after stateful DITL mutations. Snapshot at pipe build time; after any execution path that can reconfigure the pipe,
isDirtySincetells you the pipe is no longer the pipe you built. - Cache invalidation. Key memoized results on
stateSnapshot(); a structural change recomputes, a cosmetic re-run does not. - Regression guards in tests. Assert that an initializer’s side effects left the pipe structurally intact by comparing against a baseline snapshot.
Behavioral Contract
- Determinism: same semantic state, same options, same JVM → same digest. Map/Set insertion order never changes the hash (entries/elements are canonically sorted); List order does.
- Cycle safety: reference cycles terminate via an identity guard and emit a stable cycle marker.
- Caps: the walk stops descending at
maxDepth(default 64) or aftermaxObjectsencodings (default 10000), emitting a stable truncated marker — capped graphs hash deterministically, just differently from uncapped ones. - Throwing getters degrade to a stable exception-class marker instead of aborting the whole fingerprint.
- Function values encode a marker by default (two fresh pipes with equivalent lambda defaults hash equal); pass
setEncodeFunctionIdentity(true)to make distinct lambda instances hash differently. - NaN payloads hash by raw bits — two differently-encoded NaNs differ.
- No Unsafe: the implementation uses neither
sun.misc.Unsafenor native code, so the fingerprint stays valid as the JDK marches forward.
What Is Included, What Is Excluded
Included: the full declared and inherited property surface of every reachable object — including private properties (the memberProperties seam that cloneInstance persists, Util.kt:804-812; in this build the seam empirically exposes private members, so they are hashed by default), transitive objects, List contents, Map/Set entries, arrays, strings, enums, and primitives. A caller who wants the narrower boundary can opt out per property with StateHashOptions().exclude { property -> ... }.
Excluded by default: properties annotated @RuntimeState (per-execution state: counters, pause flags, session caches, generated identities) or annotated with any annotation whose qualified name contains Transient (covers @kotlinx.serialization.Transient). Override the whole boundary with StateHashOptions().exclude { property -> ... }.
Note on generated identifiers: Pipe.pipeId (Pipe.kt:2069) is a per-instance generated UUID and is hashed by design — it is part of the clone-persisted boundary. Two independently built pipes therefore hash equal only when their generated identifiers match. Pin them explicitly (or exclude pipeId via exclude {}) when you want pure structural parity.
Limitations
- Bare Java fields without accessors are outside the surface — the walk is property-based, so a Java class field that exposes no getter/setter pair is invisible to it. Kotlin private properties are inside the surface (the seam includes them); narrow the boundary with
StateHashOptions().exclude { ... }when needed. - In-JVM guarantee: digests are guaranteed stable within one JVM run-to-run; comparing digests across JVM restarts or processes is not promised.
- Capped graphs produce a truncated-but-stable digest; raising the caps after the fact changes the hash.