Chapter 9: Temporal Graphs

Relationships change over time. People switch jobs, supply chains reconfigure, permissions are granted and revoked. AstraeaDB's temporal graph model lets you capture when a relationship was active, and query the graph as it existed at any point in the past.

Prerequisites This chapter assumes you have completed Chapters 3–6 and are comfortable creating nodes, edges, and running basic queries. You will also use the traversal operations introduced in Chapter 6.

9.1 Validity Intervals

In a traditional graph database, an edge either exists or it does not. In AstraeaDB, every edge can optionally carry a validity interval: a pair of timestamps [valid_from, valid_to) that defines the period during which that edge is considered "active."

The rules

Timeline for Alice's employment: 2021-01-01 2023-01-01 2025-01-01 now │ │ │ │ ├────────────────────┤ ├───────────────► │ WORKS_AT │ │ WORKS_AT │ Company A │ │ Company B │ role: "Engineer" │ │ role: "Senior Engineer" │ │ │ ────┴────────────────────┴────────────────────┴───────────────► time

Creating temporal edges

The following example models Alice's employment history. She worked at Company A from January 1, 2021 through January 1, 2023, then moved to Company B starting January 1, 2023 (ongoing).

from astraeadb.client import JsonClient

client = JsonClient("localhost")
client.connect()

# Create nodes
alice     = client.create_node(["Person"], {"name": "Alice"})
company_a = client.create_node(["Company"], {"name": "Acme Corp"})
company_b = client.create_node(["Company"], {"name": "Globex Inc"})

# Temporal edge: Alice worked at Acme Corp from 2021 to 2023
client.create_edge(
    alice, company_a, "WORKS_AT",
    {"role": "Engineer"},
    valid_from=1609459200000,   # Jan 1, 2021 00:00 UTC
    valid_to=1672531200000     # Jan 1, 2023 00:00 UTC
)

# Temporal edge: Alice works at Globex Inc from 2023 onward (ongoing)
client.create_edge(
    alice, company_b, "WORKS_AT",
    {"role": "Senior Engineer"},
    valid_from=1672531200000    # Jan 1, 2023 00:00 UTC
    # valid_to omitted = ongoing
)

# Non-temporal edge (always active)
bob = client.create_node(["Person"], {"name": "Bob"})
client.create_edge(alice, bob, "KNOWS", {"since": "college"})
library(astraea)

client <- AstraeaClient$new("localhost")
client$connect()

# Create nodes
alice     <- client$create_node(c("Person"),  list(name = "Alice"))
company_a <- client$create_node(c("Company"), list(name = "Acme Corp"))
company_b <- client$create_node(c("Company"), list(name = "Globex Inc"))

# Temporal edge: Alice worked at Acme Corp from 2021 to 2023
client$create_edge(
  from_node  = alice,
  to_node    = company_a,
  label      = "WORKS_AT",
  properties = list(role = "Engineer"),
  valid_from = 1609459200000,   # Jan 1, 2021
  valid_to   = 1672531200000    # Jan 1, 2023
)

# Temporal edge: Alice works at Globex Inc from 2023 onward
client$create_edge(
  from_node  = alice,
  to_node    = company_b,
  label      = "WORKS_AT",
  properties = list(role = "Senior Engineer"),
  valid_from = 1672531200000    # Jan 1, 2023 (ongoing)
)

# Non-temporal edge (always active)
bob <- client$create_node(c("Person"), list(name = "Bob"))
client$create_edge(alice, bob, "KNOWS", list(since = "college"))
package main

import (
    "fmt"
    "log"
    astraea "github.com/AstraeaDB/AstraeaDB-Official"
)

func main() {
    client, err := astraea.NewJSONClient("localhost", 7687)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    // Create nodes
    alice, _ := client.CreateNode([]string{"Person"},
        map[string]interface{}{"name": "Alice"}, nil)
    companyA, _ := client.CreateNode([]string{"Company"},
        map[string]interface{}{"name": "Acme Corp"}, nil)
    companyB, _ := client.CreateNode([]string{"Company"},
        map[string]interface{}{"name": "Globex Inc"}, nil)

    // Temporal edge: Alice at Acme Corp 2021-2023
    client.CreateEdge(alice, companyA, "WORKS_AT",
        map[string]interface{}{"role": "Engineer"},
        astraea.WithValidity(1609459200000, 1672531200000),
    )

    // Temporal edge: Alice at Globex Inc from 2023 (ongoing)
    client.CreateEdge(alice, companyB, "WORKS_AT",
        map[string]interface{}{"role": "Senior Engineer"},
        astraea.WithValidFrom(1672531200000),
    )

    fmt.Println("Temporal edges created.")
}
import com.astraeadb.client.JsonClient;
import java.util.*;

public class TemporalExample {
    public static void main(String[] args) {
        JsonClient client = new JsonClient("localhost", 7687);
        client.connect();

        // Create nodes
        String alice    = client.createNode(List.of("Person"),
                             Map.of("name", "Alice"));
        String companyA = client.createNode(List.of("Company"),
                             Map.of("name", "Acme Corp"));
        String companyB = client.createNode(List.of("Company"),
                             Map.of("name", "Globex Inc"));

        // Temporal edge: Alice at Acme Corp 2021-2023
        client.createEdge(
            alice, companyA, "WORKS_AT",
            Map.of("role", "Engineer"),
            1609459200000L,    // valid_from: Jan 1, 2021
            1672531200000L     // valid_to:   Jan 1, 2023
        );

        // Temporal edge: Alice at Globex Inc from 2023 (ongoing)
        client.createEdge(
            alice, companyB, "WORKS_AT",
            Map.of("role", "Senior Engineer"),
            1672531200000L,    // valid_from: Jan 1, 2023
            0L                // valid_to: 0 = ongoing
        );

        System.out.println("Temporal edges created.");
        client.close();
    }
}
Timestamp precision All timestamps are in milliseconds since the Unix epoch (January 1, 1970 UTC). Be careful not to pass seconds-based timestamps—a value of 1672531200 (seconds) corresponds to the year 1970, not 2023. Multiply by 1000 to convert seconds to milliseconds.

9.2 Point-in-Time Queries

The most fundamental temporal operation is point-in-time neighbor lookup: "Which nodes were connected to X at time T?" The neighbors_at method works exactly like the regular neighbors call, but it filters edges based on whether they were active at the specified timestamp.

API

neighbors_at(node_id, direction, timestamp) returns only edges where valid_from <= timestamp < valid_to (or where no interval is set, meaning always-active edges).

Example: Where did Alice work in 2022?

# July 1, 2022 in milliseconds
mid_2022 = 1656633600000

# Who/what was Alice connected to in mid-2022?
neighbors = client.neighbors_at(
    node_id=alice,
    direction="outgoing",
    timestamp=mid_2022
)

for n in neighbors:
    print(f"Edge: {n['edge_label']}  "
          f"Target: {n['node_properties']['name']}  "
          f"Role: {n['edge_properties'].get('role', 'N/A')}")

# Output:
# Edge: WORKS_AT  Target: Acme Corp  Role: Engineer
# Edge: KNOWS     Target: Bob        Role: N/A
#
# Notice: the Globex Inc edge does NOT appear, because it
# starts on Jan 1, 2023 (after our query timestamp).

# Now query at mid-2024
mid_2024 = 1719792000000
neighbors_2024 = client.neighbors_at(alice, "outgoing", mid_2024)

for n in neighbors_2024:
    print(f"Edge: {n['edge_label']}  "
          f"Target: {n['node_properties']['name']}")

# Output:
# Edge: WORKS_AT  Target: Globex Inc
# Edge: KNOWS     Target: Bob
#
# Now Acme Corp is gone and Globex Inc appears.
# July 1, 2022 in milliseconds
mid_2022 <- 1656633600000

# Who/what was Alice connected to in mid-2022?
neighbors <- client$neighbors_at(
  node_id   = alice,
  direction = "outgoing",
  timestamp = mid_2022
)

for (n in neighbors) {
  cat(sprintf("Edge: %s  Target: %s  Role: %s\n",
              n$edge_label,
              n$node_properties$name,
              ifelse(is.null(n$edge_properties$role),
                     "N/A", n$edge_properties$role)))
}

# Now query at mid-2024
mid_2024 <- 1719792000000
neighbors_2024 <- client$neighbors_at(alice, "outgoing", mid_2024)
for (n in neighbors_2024) {
  cat(sprintf("Edge: %s  Target: %s\n",
              n$edge_label, n$node_properties$name))
}
// July 1, 2022 in milliseconds
var mid2022 int64 = 1656633600000

// Who/what was Alice connected to in mid-2022?
neighbors, _ := client.NeighborsAt(alice, "outgoing", mid2022)

for _, n := range neighbors {
    fmt.Printf("Edge: %s  Target: %s  Role: %v\n",
        n.EdgeLabel, n.NodeProperties["name"],
        n.EdgeProperties["role"])
}

// Now query at mid-2024
var mid2024 int64 = 1719792000000
neighbors2024, _ := client.NeighborsAt(alice, "outgoing", mid2024)

for _, n := range neighbors2024 {
    fmt.Printf("Edge: %s  Target: %s\n",
        n.EdgeLabel, n.NodeProperties["name"])
}
// July 1, 2022 in milliseconds
long mid2022 = 1656633600000L;

// Who/what was Alice connected to in mid-2022?
List<NeighborResult> neighbors =
    client.neighborsAt(alice, "outgoing", mid2022);

for (NeighborResult n : neighbors) {
    System.out.printf("Edge: %s  Target: %s  Role: %s%n",
        n.getEdgeLabel(),
        n.getNodeProperties().get("name"),
        n.getEdgeProperties().getOrDefault("role", "N/A"));
}

// Now query at mid-2024
long mid2024 = 1719792000000L;
List<NeighborResult> neighbors2024 =
    client.neighborsAt(alice, "outgoing", mid2024);

for (NeighborResult n : neighbors2024) {
    System.out.printf("Edge: %s  Target: %s%n",
        n.getEdgeLabel(),
        n.getNodeProperties().get("name"));
}

9.3 Temporal BFS and Shortest Path

Point-in-time queries extend naturally to multi-hop traversals. AstraeaDB provides temporal-aware variants of BFS and shortest path that only traverse edges active at the specified timestamp.

Temporal BFS

bfs_at(start, max_depth, timestamp) performs a breadth-first search from the start node, but at each hop it only follows edges whose validity interval contains the given timestamp.

Temporal Shortest Path

shortest_path_at(from_node, to_node, timestamp) finds the shortest path between two nodes using only edges that were active at the specified time.

Example: Organizational path in 2022

Suppose we have modeled a company's reporting structure with temporal REPORTS_TO edges. We can ask: "What was the chain of command from Alice to the CEO in mid-2022?"

mid_2022 = 1656633600000

# ── Temporal BFS ──
# Discover all nodes reachable from Alice within 5 hops at mid-2022
bfs_result = client.bfs_at(
    start=alice,
    max_depth=5,
    timestamp=mid_2022
)

print(f"Nodes reachable from Alice in mid-2022: {len(bfs_result)}")
for node in bfs_result:
    print(f"  depth={node['depth']}  {node['properties']['name']}")

# ── Temporal Shortest Path ──
# What was the reporting chain from Alice to the CEO in mid-2022?
path = client.shortest_path_at(
    from_node=alice,
    to_node=ceo_id,
    timestamp=mid_2022
)

if path:
    print("Reporting chain in mid-2022:")
    for step in path:
        print(f"  {step['properties']['name']} ({step['labels']})")
    # Example output:
    # Reporting chain in mid-2022:
    #   Alice (['Person'])
    #   Bob (['Person'])            -- Alice's manager in 2022
    #   Carol (['Person'])          -- VP Engineering in 2022
    #   Dave (['Person'])           -- CEO
else:
    print("No path found at that timestamp.")
mid_2022 <- 1656633600000

# ── Temporal BFS ──
bfs_result <- client$bfs_at(
  start     = alice,
  max_depth = 5,
  timestamp = mid_2022
)

cat(sprintf("Nodes reachable from Alice in mid-2022: %d\n",
            length(bfs_result)))
for (node in bfs_result) {
  cat(sprintf("  depth=%d  %s\n", node$depth, node$properties$name))
}

# ── Temporal Shortest Path ──
path <- client$shortest_path_at(
  from_node = alice,
  to_node   = ceo_id,
  timestamp = mid_2022
)

if (length(path) > 0) {
  cat("Reporting chain in mid-2022:\n")
  for (step in path) {
    cat(sprintf("  %s\n", step$properties$name))
  }
}
var mid2022 int64 = 1656633600000

// ── Temporal BFS ──
bfsResult, _ := client.BFSAt(alice, 5, mid2022)

fmt.Printf("Nodes reachable from Alice in mid-2022: %d\n",
    len(bfsResult))
for _, node := range bfsResult {
    fmt.Printf("  depth=%d  %s\n",
        node.Depth, node.Properties["name"])
}

// ── Temporal Shortest Path ──
path, _ := client.ShortestPathAt(alice, ceoID, mid2022)

if len(path) > 0 {
    fmt.Println("Reporting chain in mid-2022:")
    for _, step := range path {
        fmt.Printf("  %s\n", step.Properties["name"])
    }
}
long mid2022 = 1656633600000L;

// ── Temporal BFS ──
List<BfsNode> bfsResult = client.bfsAt(alice, 5, mid2022);

System.out.printf("Nodes reachable from Alice in mid-2022: %d%n",
    bfsResult.size());
for (BfsNode node : bfsResult) {
    System.out.printf("  depth=%d  %s%n",
        node.getDepth(), node.getProperties().get("name"));
}

// ── Temporal Shortest Path ──
List<PathNode> path = client.shortestPathAt(alice, ceoId, mid2022);

if (!path.isEmpty()) {
    System.out.println("Reporting chain in mid-2022:");
    for (PathNode step : path) {
        System.out.printf("  %s%n",
            step.getProperties().get("name"));
    }
}
Comparing time periods Run the same query at two different timestamps to see how the graph evolved. For example, run shortest_path_at at mid-2022 and again at mid-2024 to see how the reporting chain changed after a reorganization.

9.4 Use Cases

Temporal graphs are useful whenever the relationships in your domain change over time. Here are four common patterns:

Organizational History

Track reporting structures, team assignments, and role changes over time. Answer questions like "Who was Alice's manager in Q3 2022?" or "How did the engineering org structure change after the merger?" Each REPORTS_TO and MEMBER_OF edge carries a validity interval, so no data is lost when relationships change.

Network Evolution

Model how a social network, communication network, or infrastructure topology grew and changed. Analyze which connections appeared or disappeared during specific events. Combine with graph algorithms (Chapter 10) to compute centrality or community structure at any historical point.

Compliance and Audit

Prove the state of access permissions, data flows, or approval chains as they existed at a specific date. Auditors can query "Who had write access to the production database on March 15th?" and receive an answer that reflects the permission graph at that exact moment—not the current state.

Supply Chain Tracking

Supplier relationships, shipping routes, and inventory locations change frequently. Temporal edges let you answer "Which suppliers were feeding our Munich factory when the disruption hit on June 3rd?" and compare with the current supplier graph to understand what changed.

Performance note Temporal queries do not duplicate the graph for each time point. AstraeaDB uses persistent data structures internally: each edge stores its validity interval, and temporal queries filter edges during traversal. The storage overhead is just two additional integers (8 bytes each) per temporal edge. There is no need to create separate "snapshot" copies of your graph.
← Ch 8: Vector Search Ch 10: Graph Algorithms →