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.
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
valid_fromandvalid_toare Unix timestamps in milliseconds (milliseconds since January 1, 1970 UTC).- An edge is active at time
tifvalid_from <= t < valid_to. The interval is half-open: inclusive on the left, exclusive on the right. - If
valid_tois omitted (or set tonull/0), the edge is considered ongoing—it is active fromvalid_fromthrough the present and into the future. - If neither
valid_fromnorvalid_tois set, the edge behaves like a traditional edge: it is always active. - Multiple edges between the same two nodes can have non-overlapping intervals, representing the same relationship at different times (e.g., Alice worked at Company A from 2021–2023, then returned in 2025).
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(); } }
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")); } }
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.