Skip to content

Time Travel#

Concourse automatically tracks every change to every piece of data, creating a complete version history. This enables you to query and read data as it existed at any point in the past.

Temporal Queries vs Temporal Reads#

There are two distinct ways to access historical data:

Temporal Queries#

A temporal query evaluates its selection criteria based on the version of data at a previous timestamp. The query returns the records that would have matched the criteria at that point in time.

1
2
3
4
5
// Java
// Find records where age > 30 as of last month
Set<Long> records = concourse.find(
    "age > 30",
    Timestamp.fromString("last month"));

Temporal Reads#

A temporal read retrieves the version of the data that existed at a previous timestamp. The read returns what the data looked like at that point in time.

1
2
3
4
// Java
// Get the name that was stored in record 1 last month
Object name = concourse.get("name", 1,
    Timestamp.fromString("last month"));

Combining Both#

You can combine temporal queries and temporal reads in a single operation:

1
2
3
4
5
6
// Java
// Select data from records matching criteria, both
// evaluated at the same historical timestamp
Map<Long, Map<String, Set<Object>>> data =
    concourse.select("age > 30",
        Timestamp.fromString("last month"));

Timestamps#

Most Concourse methods accept an optional Timestamp parameter for time travel.

Creating Timestamps#

1
2
3
4
5
6
// Java
Timestamp now = Timestamp.now();
Timestamp specific =
    Timestamp.fromMicros(1609459200000000L);
Timestamp fromDate =
    Timestamp.fromJoda(new DateTime(2025, 1, 1, 0, 0));

Natural Language Timestamps#

Concourse supports natural language time expressions, which are especially convenient in the CaSH shell:

1
2
3
4
5
6
// CaSH
get "name" from 1 at "yesterday"
get "name" from 1 at "3 hours ago"
get "name" from 1 at "last Tuesday"
get "name" from 1 at "January 1, 2025"
get "name" from 1 at "2 weeks ago"
1
2
3
// Java
Timestamp ts = Timestamp.fromString("3 days ago");
Object name = concourse.get("name", 1, ts);

Per-Key Timestamps#

The trailing at / as of clause shown above pins every read in an operation to a single timestamp. For mixed-time questions — “give me a customer’s current tier alongside their lifetime spend as of last quarter” — you can attach a per-key timestamp directly to the key with bracket annotations.

Bracket Syntax#

Append [<timestamp>] to any key to pin that key’s read to a specific moment:

1
2
3
select [tier, lifetime_spend["end of last quarter"]] from 1
-- tier reads at the present moment
-- lifetime_spend reads at the close of last quarter

Bracket annotations work wherever a read or query accepts a key: selection keys, leaf keys in where conditions, navigation stops, and order by keys. The bracket value can be a numeric microsecond timestamp or any natural-language expression that Timestamp.fromString accepts:

1
2
3
4
select name[1672531200000000] from 1
select name["yesterday"] from 1
select name["January 1, 2024"] from 1
select name["Q3 2024"] from 1

Precedence#

When both a bracket and a trailing at/as of are present, the bracket always wins for the key it sits on. Unbracketed keys default to the trailing at clause when one is supplied, or to the present moment otherwise:

1
2
3
4
5
-- Brackets win; the trailing clause fills in for unbracketed keys
select [tier["last week"], lifetime_spend] from 1
    at "end of last quarter"
-- tier reads at last week (bracket wins)
-- lifetime_spend reads at end of last quarter (default-fill)

This rule applies uniformly to selection keys, navigation stops, and the leaf keys in where conditions.

Order keys behave differently across commands

order by keys have a known wrinkle today that may be reconciled in a future release. On find and other condition-only commands, an unbracketed sort key reads at the present moment regardless of the trailing at. On select, get, and other commands that materialize a result set, an unbracketed sort key inherits the trailing at so the sort stays consistent with the materialized values. To pin a sort read explicitly and uniformly across both families, bracket the key directly (order by name["last week"]) or pin the order clause (order by name asc at "last week").

Mixed-Time Conditions#

Conditions in a where clause evaluate each leaf at its own pinned moment, so a single query can match records by their state at multiple points in time:

1
2
3
4
find tier = "PLATINUM"
    and lifetime_spend["end of last quarter"] >= 100000
-- evaluates tier against the present moment
-- evaluates lifetime_spend at the close of last quarter

Per-Stop Brackets on Navigation#

Each stop of a navigation path can carry its own bracket independently. The same precedence applies per stop:

1
2
3
4
5
6
7
8
9
-- Pin only the link traversal; read each destination's current
-- salary
select manager["last week"].salary from 1

-- Pin both the link traversal and the destination read
select manager["last week"].salary["last week"] from 1

-- Same effect as above, expressed via the legacy trailing clause
select manager.salary from 1 at "last week"

Where Brackets Are Rejected#

Bracket annotations are rejected on commands where a per-key point-in-time would be meaningless:

  • Writesadd, set, remove, clear, link, unlink, reconcile, verify_and_swap, verify_or_set, find_or_add, revert.
  • Range-history readsaudit, chronicle, diff. These carry their time window in the from/to parameters, not on the key.

The server rejects bracket-bearing keys on these commands at parse time with an error that names both the command and the offending key.

Java API#

Brackets in CCL strings round-trip through the typed Java API unchanged:

1
2
3
4
// Java
Map<Long, Map<String, Set<Object>>> data = concourse.select(
    "select [tier, lifetime_spend[\"end of last quarter\"]]"
    + " from 1");

The Order builder exposes per-key timestamps programmatically:

1
2
3
// Java
Order order = Order.by("score").at(
    Timestamp.fromString("yesterday")).descending();

Audit#

The audit method returns a complete revision history for a record or a specific field. Each entry maps a commit timestamp to a list of human-readable descriptions of the changes made.

Audit an Entire Record#

1
2
3
// Java
Map<Timestamp, List<String>> history =
    concourse.audit(1);
1
2
// CaSH
audit 1

Audit a Specific Key#

1
2
3
// Java
Map<Timestamp, List<String>> history =
    concourse.audit("name", 1);
1
2
// CaSH
audit "name", 1

Audit a Time Range#

Restrict the audit to changes within a specific time window (start inclusive, end exclusive):

1
2
3
4
5
6
7
// Java
Timestamp start =
    Timestamp.fromString("January 1, 2025");
Timestamp end =
    Timestamp.fromString("February 1, 2025");
Map<Timestamp, List<String>> history =
    concourse.audit(1, start, end);

Chronicle#

The chronicle method returns a time series of the values stored for a key in a record after every change. Unlike audit, which describes what changed, chronicle shows the state of the field after each change.

1
2
3
4
5
// Java
Map<Timestamp, Set<Object>> timeline =
    concourse.chronicle("name", 1);
// e.g., {T1 -> ["Jeff"], T2 -> ["Jeff", "Jeffrey"],
//         T3 -> ["Jeffrey"]}
1
2
// CaSH
chronicle "name", 1

Chronicle a Time Range#

1
2
3
4
// Java
Map<Timestamp, Set<Object>> timeline =
    concourse.chronicle("name", 1,
        Timestamp.fromString("last month"));

Diff#

The diff method returns the net changes between two points in time. Unlike audit, which records every individual change, diff computes the minimal set of additions and removals needed to transition from the start state to the end state.

Diff a Field#

1
2
3
4
5
// Java
Map<Diff, Set<Object>> changes =
    concourse.diff("name", 1,
        Timestamp.fromString("last week"));
// e.g., {ADDED -> ["Jeffrey"], REMOVED -> ["Jeff"]}

Diff an Entire Record#

1
2
3
4
// Java
Map<String, Map<Diff, Set<Object>>> changes =
    concourse.diff(1,
        Timestamp.fromString("last week"));

Diff a Key Across All Records#

1
2
3
4
5
// Java
Map<Object, Map<Diff, Set<Long>>> changes =
    concourse.diff("status",
        Timestamp.fromString("last week"));
// Shows which values were added/removed and in which records

Diff Between Two Timestamps#

All diff variants accept a start and optional end timestamp. If only a start is provided, the diff covers from the start to the present.

1
2
3
4
5
6
7
// Java
Timestamp start =
    Timestamp.fromString("January 1, 2025");
Timestamp end =
    Timestamp.fromString("February 1, 2025");
Map<Diff, Set<Object>> changes =
    concourse.diff("name", 1, start, end);

Revert#

The revert method atomically restores a key in a record to its state at a previous timestamp. Concourse computes the necessary adds and removes to recreate the historical state, then applies them as new revisions. The original history is preserved.

1
2
3
// Java
concourse.revert("name", 1,
    Timestamp.fromString("last week"));
1
2
// CaSH
revert "name", 1, "last week"

Revert Multiple Keys#

1
2
3
4
// Java
concourse.revert(
    Lists.newArrayList("name", "email"), 1,
    Timestamp.fromString("yesterday"));

Revert Across Multiple Records#

1
2
3
4
// Java
concourse.revert("status",
    Lists.newArrayList(1L, 2L, 3L),
    Timestamp.fromString("last week"));

Revert creates new revisions

Reverting does not delete history. Instead, it creates new revisions that undo the changes made since the target timestamp. The full audit trail, including the revert operations themselves, is preserved.

Historical Reads#

All standard read methods support a Timestamp parameter for historical lookups:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Java
// Historical select
Map<String, Set<Object>> data = concourse.select(1,
    Timestamp.fromString("yesterday"));

// Historical get
Object name = concourse.get("name", 1,
    Timestamp.fromString("yesterday"));

// Historical browse
Map<Object, Set<Long>> index = concourse.browse(
    "status",
    Timestamp.fromString("last month"));

// Historical describe
Set<String> keys = concourse.describe(1,
    Timestamp.fromString("last year"));

// Historical verify
boolean existed = concourse.verify("name", "Jeff", 1,
    Timestamp.fromString("2 days ago"));

Historical Navigation#

Navigation and trace methods also support temporal lookups:

1
2
3
4
5
6
7
8
// Java
Map<Long, Set<Object>> results =
    concourse.navigate("employer.name", 1,
        Timestamp.fromString("last quarter"));

Map<String, Set<Long>> incoming =
    concourse.trace(100,
        Timestamp.fromString("6 months ago"));