Temporal Properties & Time-Travel¶
Grafeo has two complementary temporal features:
- Temporal types: Date, Time, Duration and zoned variants for storing temporal data. They are always available.
- History and time travel: a database can keep the committed states of a retention window, and any query, view or direct read can then read the database as it was at an earlier epoch or time. History is off by default and costs nothing while it is off.
The temporal feature flag
Since 0.6.0 the temporal Cargo feature is deprecated and enables nothing: history is a per-database setting (see History retention), and the history APIs are in every build. It is removed in 0.7.0.
Temporal Types¶
Temporal types are first-class values that can be stored as properties, used in expressions and returned from queries. They are available in all query languages and all bindings.
Supported Types¶
| Type | Example | Description |
|---|---|---|
DATE | 2024-01-15 | Calendar date (year, month, day) |
TIME | 14:30:00 | Local time (hour, minute, second) |
DATETIME | 2024-01-15T14:30:00 | Local date and time |
DURATION | P1Y2M3D | ISO 8601 duration |
ZONED DATETIME | 2024-01-15T14:30:00+01:00 | Datetime with UTC offset |
ZONED TIME | 14:30:00+01:00 | Time with UTC offset |
Typed Literals¶
Inline temporal values use the typed literal syntax:
RETURN DATE '2024-01-15'
RETURN TIME '14:30:00'
RETURN DATETIME '2024-01-15T14:30:00Z'
RETURN DURATION 'P1Y2M3D'
RETURN ZONED DATETIME '2024-01-15T14:30:00+01:00'
RETURN ZONED TIME '14:30:00+01:00'
String Constructors¶
Parse temporal values from ISO 8601 strings:
RETURN date('2024-01-15')
RETURN time('14:30:00')
RETURN datetime('2024-01-15T14:30:00')
RETURN duration('P1Y2M')
Map Constructors¶
Build temporal values from named components:
RETURN date({year: 2024, month: 3})
RETURN time({hour: 14, minute: 30, second: 45})
RETURN datetime({year: 2024, month: 3, day: 15, hour: 14, minute: 30})
RETURN duration({years: 1, months: 2, days: 3})
Omitted components default to their zero value (1 for month/day, 0 for time components).
Arithmetic¶
Durations can be added to or subtracted from dates and datetimes:
-- 30 days after a date
RETURN DATE '2024-01-15' + DURATION 'P30D'
-- Result: 2024-02-14
-- Find events in the last 7 days
MATCH (e:Event)
WHERE e.created_at > now() - DURATION 'P7D'
RETURN e.title, e.created_at
Component Extraction¶
Extract individual components from temporal values:
LET d = DATE '2024-06-15'
RETURN year(d), month(d), day(d)
-- 2024, 6, 15
LET t = TIME '14:30:45'
RETURN hour(t), minute(t), second(t)
-- 14, 30, 45
For the full list of temporal functions, see the Temporal Functions reference.
History Retention¶
A database keeps the versions that open transactions still read, and with a retention window also the versions of every commit within that window. Retention is a setting of the database, stored in its file, so a reopen keeps it:
| Setting | What the database keeps |
|---|---|
HistoryRetention::Off (default) | only what open transactions read; no extra cost |
HistoryRetention::Duration(d) | every committed state of the last d |
HistoryRetention::Forever | every committed state |
How It Works¶
- Every commit advances the database epoch and records its time.
- Garbage collection keeps every version a reader at an epoch inside the window still needs: the horizon is the older of the oldest open snapshot and the start of the window.
- A checkpoint writes the kept versions with the data, and a crash or a reopen keeps them: the history of a window reads the same after a restart.
- A read before what the database keeps fails with "history before epoch ... is not retained": it never answers with versions garbage collection may already have dropped.
Changing the retention takes effect from then on: what was collected before stays gone.
Time-Travel Queries¶
Available since 0.5.13.
Time-travel lets you run any query against a historical database snapshot. The results reflect the state of nodes, edges, properties and labels as of the specified epoch.
execute_at_epoch¶
The simplest way to query a past state: pass an epoch number alongside the query.
as_of: a view of the database at a point in time¶
GrafeoDB::as_of gives a read-only view of the database at a point in time, which you query like the database itself. The view holds its epoch while it lives, so its reads stay the same. A point in time is an epoch, a time, a GQL ZONED DATETIME value, or a duration ago. The names follow SQL:2011's FOR SYSTEM_TIME AS OF.
import datetime
# The database as it was five minutes ago; the view is released at the end of the block.
with db.as_of(datetime.timedelta(minutes=5)) as view:
people = view.execute("MATCH (p:Person) RETURN p.name")
# One query at an epoch or a time, without keeping a view.
then = db.execute_as_of("MATCH (p:Person) RETURN count(p)", then_epoch)
noon = datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=1)))
at_noon = db.execute_as_of("MATCH (p:Person) RETURN count(p)", noon)
# The epoch a point in time reads at.
epoch = db.epoch_as_of(datetime.timedelta(minutes=5))
use std::time::Duration;
use grafeo::TimePoint;
// The database as it was five minutes ago.
let view = db.as_of(TimePoint::ago(Duration::from_secs(300)))?;
let people = view.execute("MATCH (p:Person) RETURN p.name")?;
// One query at an epoch or a time, without keeping a view.
let then = db.execute_as_of("MATCH (p:Person) RETURN count(p)", then_epoch)?;
// The epoch a point in time reads at.
let epoch = db.epoch_as_of(TimePoint::ago(Duration::from_secs(300)))?;
In Python a point is an int (an epoch), a timezone-aware datetime (a time) or a timedelta (that long ago); a naive datetime is a ValueError, as it names no instant. In Node.js and WASM it is a bigint (an epoch), a Date or { ago: milliseconds }; a plain number is a TypeError, since it could be either an epoch or a time. The C API takes a point as a kind and a value (GRAFEO_POINT_EPOCH, GRAFEO_POINT_TIME, GRAFEO_POINT_AGO), and C#, Go and Dart wrap it in a TimePoint type.
A write through a view fails: a view of the past is read-only.
Session-Level Viewing Epoch¶
For multiple queries against the same historical snapshot, set a viewing epoch on the session. All subsequent queries will see the database as of that epoch until the override is cleared.
Inside an open transaction, execute_at_epoch and set_viewing_epoch refuse an epoch before the retained history, as outside one, and read committed data at that epoch, never later than the transaction's own snapshot. The transaction's own writes show again once the viewing epoch is cleared. An epoch past the latest commit reads at the latest completed commit, also while a commit is running or after one failed partway.
Version History APIs¶
These APIs return the full version history of a node or edge, including creation and deletion epochs.
get_node_at_epoch / get_edge_at_epoch¶
Retrieve a single node or edge as it existed at a specific epoch. It is None if the entity did not exist at that epoch; an epoch older than the history the database keeps is an error ("history before epoch ... is not retained"), never an answer.
if let Some(node) = db.get_node_at_epoch(node_id, EpochId::new(3))? {
println!("Labels: {:?}", node.labels);
println!("Properties: {:?}", node.properties);
}
if let Some(edge) = db.get_edge_at_epoch(edge_id, EpochId::new(3))? {
println!("Type: {:?}", edge.edge_type);
println!("Properties: {:?}", edge.properties);
}
get_node_history / get_edge_history¶
Return every version of a node or edge as a list of (created_epoch, deleted_epoch, entity) tuples. The deleted_epoch is None if the entity still exists.
Practical Example: Audit Trail¶
Combine temporal properties with history APIs to build an audit trail:
from grafeo import GrafeoDB
db = GrafeoDB()
# Create and update a person over several transactions
with db.begin_transaction() as tx:
tx.execute("INSERT (:Person {name: 'Gus', role: 'engineer'})")
tx.commit()
with db.begin_transaction() as tx:
tx.execute("MATCH (p:Person {name: 'Gus'}) SET p.role = 'senior engineer'")
tx.commit()
with db.begin_transaction() as tx:
tx.execute("MATCH (p:Person {name: 'Gus'}) SET p.role = 'staff engineer'")
tx.commit()
# Retrieve Gus's node ID
result = db.execute("MATCH (p:Person {name: 'Gus'}) RETURN id(p) AS nid")
gus_id = result[0]['nid']
# Walk through the full history
history = db.get_node_history(gus_id)
for created, deleted, node in history:
print(f"Epoch {created}: role = {node.properties.get('role')}")
# Epoch 1: role = engineer
# Epoch 2: role = senior engineer
# Epoch 3: role = staff engineer
Performance Considerations¶
| Retention | Cost |
|---|---|
| Off (default) | none: writes, reads and memory are the same as without history |
A window or Forever | each kept version takes about 72 bytes in memory and about 25 bytes in a checkpoint; reads at the current epoch are unchanged |
Tips¶
- Keep the window as short as your use case allows: memory and file size grow with the number of versions in it.
- Epochs advance on commit: each committed transaction or auto-commit statement is one epoch.
- History survives restarts: checkpoints, crashes and reopens keep the window's versions.