Delta Queries

Delta queries expose the incremental changes to derived Datalog relations — which tuples were added or removed — without recomputing results from scratch. Instead of returning a full snapshot, a delta query emits only what changed between two evaluation states.

wirelog’s columnar backend natively tracks changes as (data, time, diff) triples, enabling efficient delta query processing. Unlike traditional SQL systems, this change tracking is fundamental to wirelog’s architecture.

How Delta Queries Work

In conventional batch Datalog, you evaluate a program and get a complete result set. In delta mode, wirelog maintains an internal state and, when the input changes, emits only the differential — tuples entering or leaving each derived relation.

Each delta is a signed change:

Prefix Meaning
+ Tuple newly derived in this evaluation step
- Tuple retracted (no longer holds)

Deletions propagate through rules automatically. If removing an edge fact causes a derived reach(1, 5) to no longer hold, wirelog emits - reach(1, 5) without any additional program annotations.

wirelog vs RDFox

Both wirelog and RDFox support incremental Datalog over changing data, but they differ in how delta queries are exposed.

Feature wirelog RDFox
Delta mechanism C callback (wl_session_set_delta_cb) + embedding API deltaquery command + SPARQL extensions
Change input API: wl_session_insert_incremental(), direct fact retraction/insertion REST API or shell commands (import, delete)
Output format +/- prefixed tuples to stdout or callback SPARQL result sets with polarity annotations
Recursion support Non-recursive rules (MVP) Full recursive delta support
Aggregation deltas Supported (non-recursive) Supported
Language Datalog (.dl) Datalog+ / SPARQL
Embedding API C11 (libwirelog) Java / REST

RDFox exposes delta queries through its shell command:

deltaquery SELECT ?x ?y WHERE { ?x :knows ?y }

wirelog exposes them through the wl_session_set_delta_cb callback in the embedding API.

Using Delta Queries

Delta queries are available through the embedded C API. The --delta and --watch flags are not supported in the CLI; use wl_session_set_delta_cb() and wl_session_insert_incremental() instead.

Delta output format

Each changed tuple is represented as a signed change:

+ relation(val1, val2)   -- tuple newly derived
- relation(val1, val2)   -- tuple retracted

Unchanged tuples are not emitted. If no changes occurred for a given relation in a step, no output is produced for that relation.


Example 1: Relationship Tracking (Friend Suggestions)

This example tracks friend-of-a-friend suggestions. When new friendships are added or removed, only the affected suggestions change.

friend-suggestions.dl:

.decl friend(x: string, y: string)
.decl suggested(x: string, y: string)

friend("Alice", "Bob").
friend("Bob", "Carol").
friend("Carol", "Dave").

# Friend-of-a-friend who is not already a direct friend
suggested(x, z) :-
    friend(x, y),
    friend(y, z),
    x != z,
    !friend(x, z).

.output suggested

Snapshot output (initial evaluation):

suggested("Alice", "Carol")
suggested("Bob", "Dave")

With the embedding API, register a delta callback for suggested before running. After the initial run, insert friend("Alice", "Dave") incrementally:

After adding friend("Alice", "Dave"). — Alice and Dave now have a mutual friend (Bob and Carol):

+ suggested("Alice", "Dave")

After removing friend("Bob", "Carol"). — Bob no longer bridges Alice to Carol:

- suggested("Alice", "Carol")
- suggested("Alice", "Dave")
- suggested("Bob", "Dave")

Negation is fully supported in non-recursive delta programs. Retracting a fact that enabled a negated body condition (!friend(x, z)) correctly triggers downstream retractions.


Example 2: Graph Connectivity Changes

Track pairs of reachable nodes as edges are added and removed.

reachability.dl:

.decl edge(x: int32, y: int32)
.decl reach(x: int32, y: int32)

edge(1, 2).
edge(2, 3).
edge(3, 4).

reach(x, y) :- edge(x, y).
reach(x, z) :- reach(x, y), edge(y, z).

.output reach

reach is recursive. In the MVP, delta output is supported only for non-recursive output relations. To track reachability deltas, add a non-recursive summary relation and register the delta callback on it instead (see below).

Workaround — snapshot summary relation:

.decl edge(x: int32, y: int32)
.decl reach(x: int32, y: int32)
.decl reach_count(n: int32)    # non-recursive summary

edge(1, 2).
edge(2, 3).
edge(3, 4).

reach(x, y) :- edge(x, y).
reach(x, z) :- reach(x, y), edge(y, z).

reach_count(count(x)) :- reach(x, _).

.output reach_count

Register a delta callback on reach_count via the embedding API before running.

Snapshot output:

reach_count(6)

After adding edge(4, 5):

- reach_count(6)
+ reach_count(10)

After removing edge(2, 3):

- reach_count(10)
+ reach_count(5)

The delta for an aggregation result is a pair of retractions and insertions — the old value leaves and the new value enters.


Example 3: Aggregation Changes (Degree Monitoring)

Monitor the out-degree of nodes as the graph changes. Aggregations produce deltas as pairs: the old aggregate value is retracted and the new value is inserted.

degree-monitor.dl:

.decl edge(x: int32, y: int32)
.decl out_degree(x: int32, deg: int32)
.decl high_degree(x: int32)

edge(1, 2).
edge(1, 3).
edge(2, 3).

out_degree(x, count(y)) :- edge(x, y).
high_degree(x) :- out_degree(x, d), d >= 2.

.output out_degree
.output high_degree

Register delta callbacks on out_degree and high_degree via the embedding API before running.

Snapshot output:

out_degree(1, 2)
out_degree(2, 1)
high_degree(1)

After adding edge(2, 4) (node 2 gains a second neighbor):

- out_degree(2, 1)
+ out_degree(2, 2)
+ high_degree(2)

After removing edge(1, 2) (node 1 drops below the threshold):

- out_degree(1, 2)
+ out_degree(1, 1)
- high_degree(1)

Downstream rules that depend on out_degree automatically receive the correct delta, so high_degree updates without any additional program changes.


Example 4: Access Control (Permission Propagation)

Track effective permissions as role assignments change.

access-control.dl:

.decl role(user: string, r: string)
.decl permission(r: string, action: string)
.decl can(user: string, action: string)

role("alice", "editor").
role("bob", "viewer").

permission("editor", "read").
permission("editor", "write").
permission("viewer", "read").

can(u, a) :- role(u, r), permission(r, a).

.output can

Register a delta callback on can via the embedding API before running.

Snapshot output:

can("alice", "read")
can("alice", "write")
can("bob", "read")

After adding role("bob", "editor"):

+ can("bob", "write")

After removing role("alice", "editor"):

- can("alice", "read")
- can("alice", "write")

This pattern is useful for audit logs: the delta stream records exactly which permissions were granted or revoked and when.


Embedding API

When embedding wirelog as a library (libwirelog), register a callback to receive delta changes programmatically instead of reading stdout.

wl_session_set_delta_cb

typedef void (*wl_delta_cb)(
    const char    *relation,   /* relation name (null-terminated) */
    const wl_val  *tuple,      /* array of column values          */
    int            arity,      /* number of columns               */
    int            polarity,   /* +1 for addition, -1 for deletion */
    void          *userdata    /* opaque pointer passed at registration */
);

int wl_session_set_delta_cb(
    wl_session    *session,    /* active session handle           */
    const char    *relation,   /* relation to watch (NULL = all)  */
    wl_delta_cb    cb,         /* callback function               */
    void          *userdata    /* passed through to cb unchanged  */
);

Parameters:

Parameter Description
session Active wl_session created by wl_session_create
relation Relation name to monitor; pass NULL to receive deltas for all output relations
cb Callback invoked once per changed tuple per evaluation step
userdata Arbitrary pointer forwarded to every cb invocation

Return value: 0 on success, non-zero on error (e.g., unknown relation name).

Callback parameters:

Parameter Type Description
relation const char * Name of the relation that changed
tuple const wl_val * Column values; length is arity
arity int Number of columns in this relation
polarity int +1 — tuple added; -1 — tuple retracted
userdata void * Value passed at registration

wl_val — Column Value

typedef struct {
    wl_val_kind kind;   /* WL_VAL_INT or WL_VAL_STRING */
    union {
        int64_t     i;  /* integer value  */
        const char *s;  /* interned string (do not free) */
    };
} wl_val;

String pointers are owned by the wirelog symbol table and remain valid for the lifetime of the session.

Minimal Embedding Example

#include <wirelog/session.h>
#include <stdio.h>

static void on_delta(const char *rel, const wl_val *tuple,
                     int arity, int polarity, void *ud) {
    printf("%s %s(", polarity > 0 ? "+" : "-", rel);
    for (int i = 0; i < arity; i++) {
        if (i) printf(", ");
        if (tuple[i].kind == WL_VAL_INT)
            printf("%lld", (long long)tuple[i].i);
        else
            printf("\"%s\"", tuple[i].s);
    }
    printf(")\n");
}

int main(void) {
    wl_session *s = wl_session_create("program.dl", /*workers=*/1);
    wl_session_set_delta_cb(s, "can", on_delta, NULL);
    wl_session_run(s);
    wl_session_destroy(s);
    return 0;
}

Best Practices

Use non-recursive output relations for delta tracking. Attach --delta to a non-recursive summary or projection relation derived from a recursive base. This avoids the MVP restriction while keeping delta semantics correct.

# Recursive base (not delta-tracked directly)
reach(x, z) :- reach(x, y), edge(y, z).

# Non-recursive projection  safe to track with --delta
reachable_from_1(z) :- reach(1, z).

Aggregate deltas come in pairs. When an aggregate value changes, wirelog retracts the old value and inserts the new one in the same step. Consumers must handle both the - and + for the same group key.

Check polarity before acting. In the embedding API, always branch on polarity before inserting into a downstream store. Applying a retraction as an insertion will corrupt the result.

Register callbacks before running. wl_session_set_delta_cb must be called before wl_session_run. Callbacks registered after execution starts will not receive deltas from previous steps.

Prefer relation-scoped callbacks over NULL (catch-all). A catch-all callback (relation = NULL) receives deltas for every output relation and can produce unexpected volume for programs with many derived relations.


Limitations (MVP)

Limitation Detail
Non-recursive output only --delta and wl_delta_cb apply to output relations with no recursive self-dependency. Recursive relations must be projected into a non-recursive relation first.
Single program per session A wl_dd_session executes one .dl file. Incremental input changes require the embedding API; the CLI --watch mode re-runs the program on file change.
No partial-step inspection Callbacks fire once per evaluation step, after the fixed point is reached for that step. Mid-step states are not exposed.

Copyright © CleverPlant. Licensed under LGPL-3.0.

This site uses Just the Docs, a documentation theme for Jekyll.