Snapshots and Copies¶
Overview and Motivation¶
A snapshot records the complete state of an object at a point in time so that the object can later be reverted to exactly that state. The classic use case is editing: before a user starts changing a PDO in a form, the application takes a snapshot; if the user cancels, the PDO is reverted and looks exactly as it did before — including all of its loaded components and the modification flags of every object in the aggregate.
A copy is the closely related operation of producing a brand-new, independent object that carries the same data. Both operations share the same machinery and the same SPI contract, which is why they are documented together.
The single most important rule: a snapshot is not a copy of the object. It does not represent "the object as it was" as a usable second object — it only holds the information needed to revert the original back to that state. You never work with a snapshot as if it were the entity; you hand it back to the object it came from.
The contract lives in the core module as the
Snapshotable<T>
interface; the two concrete implementations that matter in practice are
TrackedList
(snapshotting a collection) and the PDO persistence delegate
AbstractPersistentObject
(snapshotting an entity and its aggregate).
The Snapshotable Contract¶
Any type that supports snapshots implements Snapshotable<T>:
| Method | Purpose |
|---|---|
T createSnapshot() |
Take a snapshot of the current state and register it. |
void revertToSnapshot(T snapshot) |
Restore the object to a previously taken snapshot. |
boolean isSnapshot() |
True if this object is itself a snapshot (snapshots are immutable). |
List<? extends T> getSnapshots() |
All snapshots still reachable, oldest first. |
void discardSnapshot(T) / discardSnapshots() |
Drop one / all snapshots. |
T copy() |
Produce an independent copy (implemented via snapshot+revert). |
boolean isCopy() / setCopy(boolean) |
Mark / query the "is a copy" state. |
Two properties hold across all implementations:
- Unbounded history. You may take as many snapshots as you like. They form an ordered timeline.
- Reverting truncates the future. Reverting to a snapshot discards that snapshot and every snapshot taken after it. The timeline cannot fork — once you go back, the later states are gone.
Snapshots are normally not held strongly¶
Implementations keep their registered snapshots through
java.lang.ref.WeakReferences. That means a snapshot that the application no
longer references can simply be garbage-collected, and it then quietly disappears
from getSnapshots(). As a result, explicitly discarding a snapshot is usually
unnecessary — just stop referencing it. discardSnapshot() exists for the cases
where you want the slot freed deterministically.
Snapshotting a Collection — TrackedList¶
TrackedList
is the framework's modification-tracking list (it knows which elements were added,
removed or replaced, which is what OR-mapping needs to decide what to write back).
It is Snapshotable, and its snapshot semantics are the model for everything else.
createSnapshot() in
TrackedArrayList:
- Clones the list (a clone is unmodified, mutable, with no removed objects).
- Registers the clone as a snapshot (weakly).
- Snapshots the structure: the set of removed objects and the modification
flags (
modified,added,removed,immutable, …). - Snapshots the elements. For every element:
- if the element is itself
Snapshotable(e.g., a component PDO), the list recursively callselement.createSnapshot()and stores that element snapshot in a parallel list (elementSnapshots); - otherwise only the reference to the element is stored.
This parallel elementSnapshots list maintains a strict 1:1 relationship with the
snapshot list, which is why snapshots are immutable — changing them would break
that correspondence.
revertToSnapshot() then has two modes:
- Normal revert (
isCopy() == false): the list restores its own structure (removed objects, flags, contents) from the snapshot, and for everySnapshotableelement callselement.revertToSnapshot(elementSnapshot)so the whole tree rolls back in lockstep. - Copy revert (
isCopy() == true): instead of restoring, it builds fresh content by callingelement.copy()on each snapshot element — this is how deep copies of aggregates are produced (see below).
The static helpers TrackedList.createSnapshot(list) and
TrackedList.revertToSnapshot(list, snapshot) null-guard these calls and are what
the generated persistence code uses.
Snapshotting a PDO¶
A PDO snapshot is taken on the persistence delegate
(AbstractPersistentObject);
the domain delegate carries no snapshot state of its own). It has to cover both
the entity's own attributes and its loaded components, so the aggregate reverts
as a unit.
Taking the snapshot — createSnapshot()¶
public T createSnapshot() {
T snapshot = PdoFactory.getInstance().create(getPdoClass(), clone()); // 1
PersistentObject<T> persistentSnapshot = snapshot.getPersistenceDelegate();
persistentSnapshot.setDomainContext(getDomainContext()); // 2
getClassVariables().getCreateComponentsInSnapshotMethod()
.invoke(this, persistentSnapshot); // 3
addSnapshot(snapshot); // 4
snapshot.setCopy(false);
return snapshot;
}
- Clone the delegate.
clone()isfinal(so applications cannot break the contract) and produces a shallow copy of the persistence object with its proxy reference cleared. The clone is then wrapped in a fresh PDO proxy. Because the attributes of a PDO are either immutable types or frozen values, a shallow clone is enough for them — there is deliberately nocreateAttributesInSnapshotstep. - Carry over the domain context so the snapshot is a fully usable PDO.
- Snapshot the components.
createComponentsInSnapshot(...)is the per-entity hook that snapshots each componentTrackedList. It is generated (see below) and marks the snapshot object withobjectIsSnapshot = true. - Register the snapshot (weakly) on the original.
Reverting — revertToSnapshot()¶
public void revertToSnapshot(T snapshot) {
if (snapshot != null) {
assertValidSnapshot(snapshot); // must be a snapshot, and *my* snapshot
applySnapshot(snapshot);
}
}
applySnapshot() invokes two generated hooks on the original object, passing the
snapshot's delegate:
revertAttributesToSnapshot(snapshot)— copies the scalar fields back.revertComponentsToSnapshot(snapshot)— reverts each component list viaTrackedList.revertToSnapshot(...).
assertValidSnapshot() enforces the two invariants: the argument must actually be
a snapshot (isSnapshot()), and — unless we are building a copy — it must be one
of this object's own registered snapshots. Reverting to a snapshot that was
already invalidated (because a later state was persisted, or because it belonged to
a different object) throws a PersistenceException with "not my snapshot".
id,serialandtableSerialare deliberately not reverted — they can only change through an actual persistence operation, so rolling them back would desynchronize the object from the database row it represents.
What gets reverted¶
revertAttributesToSnapshot runs up the inheritance chain
(AbstractDbObject → AbstractPersistentObject → generated impl), restoring at
each level:
- Generated level (e.g.
NumberPoolPersistenceImpl): every mapped attribute and its…Persistedshadow field. AbstractPersistentObject: context immutability, cache bookkeeping, expiry, the snapshot list itself, normtext, validation and persistable flags.AbstractDbObject:modified, theimmutable/finallyImmutableflags,overloadable, property support and session — but only when not a copy, because copies are always mutable and new.
How the snapshot hooks are wired (generated code)¶
The three hooks
(createComponentsInSnapshot, revertAttributesToSnapshot,
revertComponentsToSnapshot) are typed to the concrete implementation class, so
they cannot be called through a fixed interface signature. Instead
PersistentObjectClassVariables
locates them reflectively with findSnapshotMethod(), walking the class hierarchy
to find the most specific override:
The methods themselves are emitted into the persistence implementation by the
PdoRelations
and
MethodsImpl
wurblets.
For each component relation the generator produces a transient
…Snapshot field plus the create/revert logic — for example, in
NumberPoolPersistenceImpl:
private transient TrackedList<NumberRange> numberRangeListSnapshot;
protected void createComponentsInSnapshot(NumberPoolPersistenceImpl snapshot) {
super.createComponentsInSnapshot(snapshot);
snapshot.numberRangeListSnapshot = TrackedList.createSnapshot(numberRangeList);
}
protected void revertComponentsToSnapshot(NumberPoolPersistenceImpl snapshot) {
super.revertComponentsToSnapshot(snapshot);
numberRangeList = TrackedList.revertToSnapshot(numberRangeList, snapshot.numberRangeListSnapshot);
numberRangeListLoaded = snapshot.numberRangeListLoaded;
}
Because the component lists are themselves Snapshotable, the TrackedList
recursion carries the snapshot all the way down the aggregate — root, components,
sub-components — automatically.
Snapshots are immutable¶
A snapshot PDO has isSnapshot() == true, and assertMutable() is overridden to
reject any setter on it ("object is a snapshot"). This protects the parallel
element-snapshot bookkeeping in the lists from being corrupted. If you need an
editable second object, take a copy, not a snapshot.
Copies¶
copy() produces a new, independent, mutable object with the same data,
implemented elegantly on top of snapshots:
public T copy() {
T copiedPdo = on(); // a new, empty PDO
AbstractPersistentObject copiedPo = copiedPdo.getPersistenceDelegate();
DomainContext ctx = copiedPo.getDomainContext();
copiedPo.objectIsCopy = true; // changes how revert behaves
copiedPo.applySnapshot(pdo.createSnapshot()); // pour the state into the new object
copiedPo.setModified(true);
copiedPo.setDomainContext(ctx); // restore the new object's root context
return copiedPdo;
}
The trick is the objectIsCopy flag: when the new (empty) object reverts the
source's snapshot, the "copy revert" branch runs everywhere — TrackedList
rebuilds its contents by copying each element rather than reverting it, and
the attribute revert skips the identity/immutability fields. The result:
- The copy and every component are new (
isNew(),id == 0,serial == 0). - The copy is always mutable, even if the source was
finallyImmutable. - Only loaded components are copied (a snapshot captures the object as is).
If you want a logically complete copy of the whole aggregate, call
loadComponents(false)beforecopy().
A
copy()is semantically equivalent but not a technically identical clone. If you need a byte-for-byte deep copy (same ids, same serials), useObjectSerializer.copy(Object)instead, which round-trips the object through serialization.
A real-world use of copy() is the CRUD "duplicate" action in
PdoCrud:
the current PDO's components are loaded and pdo.copy() becomes the new, unsaved
entity in the form.
Worked Example¶
From
SnapshotTest:
NumberPool numberPool = ... ; // root with two NumberRange components
numberPool = numberPool.persist();
long id = numberPool.getId();
assertFalse(numberPool.isModified());
NumberPool snapshot = numberPool.createSnapshot(); // remember the saved state
// mutate the aggregate: add a component, change an attribute
NumberRange r = ...; numberPool.getNumberRangeList().add(r);
numberPool.setRealm("none");
assertEquals(numberPool.getNumberRangeList().size(), 3);
assertTrue(numberPool.isModified());
numberPool.revertToSnapshot(snapshot); // roll the whole aggregate back
assertFalse(numberPool.isModified()); // modification flag restored
assertEquals(numberPool.getId(), id); // identity untouched
assertEquals(numberPool.getRealm(), "test"); // attribute restored
assertEquals(numberPool.getNumberRangeList().size(), 2); // component list restored
Note how reverting restores the modification flag, the attribute, and the component list size in one call — the entire aggregate moves together.
If, after reverting, the object is changed again and persisted, the old snapshot is no longer valid (a newer serial now exists), and trying to revert to it throws:
numberPool = numberPool.persist(); // serial advances
numberPool.revertToSnapshot(snapshot); // -> PersistenceException "not my snapshot"
And a copy:
NumberPool copy = numberPool.copy();
assertTrue(copy.isCopy());
assertTrue(copy.isNew()); // brand new, id/serial == 0
assertEquals(copy.getNumberRangeList().size(), 2);
for (NumberRange range : copy.getNumberRangeList()) {
assertTrue(range.isNew()); // components are new too
}
numberPool.setFinallyImmutable();
assertTrue(numberPool.createSnapshot().isImmutable()); // snapshots inherit immutability
assertFalse(numberPool.copy().isImmutable()); // copies are always mutable
Rules of Thumb¶
- Use a snapshot to undo, a copy to duplicate. Never treat a snapshot as a usable second object — it is immutable and belongs to its origin.
- Reverting truncates the timeline. The snapshot you revert to, and all later ones, become invalid.
- You normally don't discard snapshots — weak references clean them up. Discard only when you want the slot freed deterministically.
- Identity is never reverted.
id/serial/tableSerialand the immutability of the original survive a revert; persisting after a revert invalidates older snapshots. - Snapshots/copies capture the object as is. Call
loadComponents(false)first if you need the full aggregate. - For a technically identical deep clone (same ids), reach for
ObjectSerializer.copy()rather thanSnapshotable.copy().
See Also¶
- pdo.md — the PDO / two-delegate architecture these snapshots operate on.
- immutable.md — the immutability model; snapshots are immutable, copies are mutable.
- operation.md — operations, the entity-less sibling of PDOs.
Snapshotable,TrackedList— the core contracts.- persistence-wurblets.md — the code generation that wires component snapshots.