Skip to content

Commit 706eda3

Browse files
committed
feat: Git-backed transaction notes and core helpers
This PR introduces a transaction core module and structured Git note support for every write operation in ChronDB, fulfilling architectural requirements for chronological traceability and external correlation/audit. Highlights: - Adds `chrondb.transaction.core` namespace. Provides a shared transaction context, unique `tx_id` generation, and helpers for capturing metadata (origin, user, flags) across all entrypoints (REST, SQL, Redis). - Implements a new `chrondb.storage.git.notes` module. Every commit now stores a JSON note under the `chrondb` namespace, containing: `tx_id`, origin (protocol/source), optional `user`, semantic flags (`bulk-load`, `migration`, `rollback`, etc.), and client/request metadata (e.g. `X-Request-Id`). - Refactors all transaction/write paths in the Git-backed storage engine to invoke these helpers. Enforces that missing notes are treated as a regression (test coverage included). - Documents new transaction block (`with-transaction`) usage, and how to pass metadata via HTTP headers or protocol attributes to propagate context into Git notes. See `docs/api.md`, "Transaction Operations" and "Transaction Metadata and Git Notes". - Adds and updates unit/integration tests for: correct note attachment, lookup with `git notes`, failure modes (e.g., repository contention, branch mutation), and regression coverage (notes always present after a commit). - Updates AGENT.md: All write paths _must_ call transaction helpers to guarantee Git note context. No destructive mutations; always append-only. - No backwards-incompatible API changes. Protocols remain consistent across REST, SQL (Postgres), and Redis wire formats. Rationale: ChronDB's Git-based architecture enables full auditability and time-travel. Storing rich transaction metadata as Git notes ensures every operation is contextualized—supporting debugging, lineage analysis, external audit, and advanced correlation across distributed systems. This change centralizes transaction and commit metadata handling, eliminating per-protocol ad-hoc logic and reducing the risk of silent context loss. See also: - `src/chrondb/storage/git/notes.clj` (implementation) - `src/chrondb/transaction/core.clj` (transaction core logic) - New/updated tests in `test/chrondb/storage/git/notes_test.clj`, `test/chrondb/transaction/core_test.clj` - Expanded documentation in `docs/api.md` and process requirements in AGENT.md. BREAKING: None, but all future write code must route through the transaction context to guarantee note coverage and protocol metadata flow. fixed #43 fixed #44 Signed-off-by: Avelino <[email protected]>
1 parent 9c46bdc commit 706eda3

File tree

19 files changed

+1136
-251
lines changed

19 files changed

+1136
-251
lines changed

AGENT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ ChronDB is a chronological key/value database implemented in Clojure and backed
4040
- Handle repository creation and concurrency with JGit primitives
4141
- Surface meaningful errors when Git operations fail (lock contention, missing refs)
4242
- Avoid destructive operations; prefer new commits over in-place mutation
43+
- Ensure every write path calls the shared transaction helpers so that Git notes capture `tx_id`, origin, user, flags, and protocol metadata. Treat missing notes as regressions.
4344

4445
### API Development
4546

docs/api.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,28 @@ All operations within the transaction block are atomic:
177177
- Changes are only visible after successful commit
178178
- Automatic rollback on failure
179179

180+
#### Transaction Metadata and Git Notes
181+
182+
ChronDB attaches a structured Git note to every commit. The payload includes the transaction id (`tx_id`), origin, optional user identifier, flags, and request metadata. You can enrich this payload by providing HTTP headers on REST requests:
183+
184+
| Header | Description |
185+
| --- | --- |
186+
| `X-ChronDB-Origin` | Overrides the origin label stored in the note (defaults to `rest`). |
187+
| `X-ChronDB-User` | Associates commits with an authenticated user id or service account. |
188+
| `X-ChronDB-Flags` | Comma-separated list of semantic flags (for example: `bulk-load, migration`). |
189+
| `X-Request-Id` / `X-Correlation-Id` | Propagate request correlation identifiers into commit metadata. |
190+
191+
All ChronDB front-ends (REST, Redis, SQL) reuse the same transaction core, so commits that belong to the same logical operation share the same `tx_id`. Special operations automatically set semantic flags. For instance, document imports mark the transaction with `bulk-load`, and restore flows add `rollback`.
192+
193+
Inspect commit notes with standard Git tooling:
194+
195+
```
196+
git log --show-notes=chrondb
197+
git notes --ref=chrondb show <commit-hash>
198+
```
199+
200+
You can parse the JSON payload to correlate commits with external systems or audit trails.
201+
180202
### Event Hooks
181203

182204
#### Register Hook
@@ -263,3 +285,5 @@ Example error handling:
263285
(log/error "Storage error:" (.getMessage e)))
264286
(catch chrondb.exceptions.ValidationException e
265287
(log/error "Validation error:" (.getMessage e))))
288+
289+
```

docs/architecture.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ ChronDB is built in layers:
4444
4. Indices are updated to reflect changes
4545
5. Reads can access any point in time using specific commits
4646

47+
### Transaction Metadata via Git Notes
48+
49+
Every commit recorded by ChronDB receives a Git note under the `chrondb` ref. The note payload is JSON and contains:
50+
51+
- A transaction identifier (`tx_id`) shared by all commits that belong to the same logical operation
52+
- The origin (for example `rest`, `redis`, `sql`, `cli`)
53+
- Optional user information and request correlation ids
54+
- Semantic flags such as `bulk-load`, `rollback`, `migration`, or `automated-merge`
55+
- Additional metadata supplied by the protocol handler (HTTP endpoint, Redis command, SQL table, etc.)
56+
57+
These notes provide an append-only audit trail without mutating commit messages or tracked files. Operators can inspect them using standard tooling:
58+
59+
```
60+
git log --show-notes=chrondb
61+
git notes --ref=chrondb show <commit>
62+
```
63+
64+
Because they live outside the object graph, notes can be replicated, filtered, and queried independently of the document contents while preserving Git’s immutable history.
65+
4766
### Indexing Layer Details
4867

4968
The Lucene layer receives document mutations from the storage layer and updates the appropriate secondary indexes. It maintains statistics about term distributions and query plans so that complex requests—such as multi-field boolean filters or temporal slices—can be executed without scanning entire collections. When a query arrives, the planner determines the optimal combination of indexes, warms the cache when necessary, and streams results back to the access layer. Geospatial fields are stored in BKD trees, while full-text fields use analyzers that can be tuned per collection.

docs/examples-clojure.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,27 @@ ChronDB supports atomic transactions:
147147
;; If any operation fails, all changes are rolled back
148148
```
149149

150+
You can customise the transaction metadata written to Git notes via the `chrondb.transaction.core` helpers:
151+
152+
```clojure
153+
(require '[chrondb.transaction.core :as tx])
154+
155+
(tx/with-transaction [db {:origin "cli"
156+
:user "admin"
157+
:flags ["migration"]
158+
:metadata {:request "seed-dataset"}}]
159+
;; Flags can be appended dynamically as context evolves
160+
(tx/add-flags! "bulk-load")
161+
(tx/merge-metadata! {:batch-size 3})
162+
163+
(doseq [doc [{:id "product:1" :name "Laptop" :price 1200}
164+
{:id "product:2" :name "Phone" :price 800}
165+
{:id "product:3" :name "Tablet" :price 600}]]
166+
(chrondb/save db (:id doc) doc)))
167+
168+
;; Inspect the resulting Git notes with: git log --show-notes=chrondb
169+
```
170+
150171
## Advanced Features
151172

152173
### Custom Hooks

src/chrondb/api/redis/core.clj

Lines changed: 105 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
[chrondb.index.protocol :as index]
66
[chrondb.query.ast :as ast]
77
[chrondb.util.logging :as log]
8+
[chrondb.transaction.core :as tx]
89
[clojure.string :as str]
910
[clojure.data.json :as json]
1011
[clojure.core.async :as async])
@@ -23,6 +24,43 @@
2324
(def RESP_OK "+OK\r\n")
2425
(def RESP_PONG "+PONG\r\n")
2526

27+
(defn- normalize-flags [flags]
28+
(letfn [(spread [value]
29+
(cond
30+
(nil? value) []
31+
(and (coll? value) (not (string? value))) (mapcat spread value)
32+
:else [value]))]
33+
(->> (spread flags)
34+
(keep identity)
35+
(map str)
36+
(remove str/blank?)
37+
distinct
38+
vec
39+
not-empty)))
40+
41+
(defn- redis-command-metadata
42+
[command args extra]
43+
(merge {:command command
44+
:arg-count (count args)}
45+
(when (seq args)
46+
{:args (mapv str (take 8 args))})
47+
(or extra {})))
48+
49+
(defn- redis-tx-options
50+
[command args {:keys [flags metadata]}]
51+
(let [normalized (normalize-flags flags)
52+
meta (redis-command-metadata command args metadata)]
53+
(cond-> {:origin "redis"
54+
:metadata meta}
55+
normalized (assoc :flags normalized))))
56+
57+
(defn execute-redis-write
58+
([storage command args f]
59+
(execute-redis-write storage command args {} f))
60+
([storage command args opts f]
61+
(tx/with-transaction [storage (redis-tx-options command args opts)]
62+
(f))))
63+
2664
;; RESP Protocol Serialization Functions
2765
(defn write-simple-string [writer s]
2866
(.write writer (str RESP_SIMPLE_STRING s CRLF)))
@@ -132,17 +170,21 @@
132170
(write-error writer "ERR wrong number of arguments for 'set' command")
133171
(let [key (first args)
134172
value (second args)
135-
doc {:id key :value value}
136-
_ (storage/save-document storage doc)
137-
_ (when index (index/index-document index doc))]
138-
(.write writer RESP_OK))))
173+
doc {:id key :value value}]
174+
(execute-redis-write storage "SET" args {:metadata {:key key}}
175+
(fn []
176+
(storage/save-document storage doc)
177+
(when index (index/index-document index doc))
178+
(.write writer RESP_OK))))))
139179

140180
(defn handle-del [storage writer args]
141181
(if (empty? args)
142182
(write-error writer "ERR wrong number of arguments for 'del' command")
143-
(let [key (first args)
144-
result (storage/delete-document storage key)]
145-
(write-integer writer (if result 1 0)))))
183+
(let [key (first args)]
184+
(execute-redis-write storage "DEL" args {:metadata {:key key} :flags ["delete"]}
185+
(fn []
186+
(let [result (storage/delete-document storage key)]
187+
(write-integer writer (if result 1 0))))))))
146188

147189
(defn handle-command [_storage _index writer _args]
148190
(.write writer RESP_OK))
@@ -162,9 +204,11 @@
162204
value (nth args 2)
163205
doc {:id key :value value :expire-at (+ (System/currentTimeMillis) (* seconds 1000))}]
164206
(when seconds
165-
(storage/save-document storage doc)
166-
(when index (index/index-document index doc))
167-
(.write writer RESP_OK)))))
207+
(execute-redis-write storage "SETEX" args {:metadata {:key key :ttl seconds}}
208+
(fn []
209+
(storage/save-document storage doc)
210+
(when index (index/index-document index doc))
211+
(.write writer RESP_OK)))))))
168212

169213
(defn handle-setnx [storage index writer args]
170214
(if (< (count args) 2)
@@ -174,10 +218,11 @@
174218
existing-doc (storage/get-document storage key)]
175219
(if existing-doc
176220
(write-integer writer 0) ; Key exists, don't set
177-
(do
178-
(storage/save-document storage {:id key :value value})
179-
(when index (index/index-document index {:id key :value value}))
180-
(write-integer writer 1))))))
221+
(execute-redis-write storage "SETNX" args {:metadata {:key key}}
222+
(fn []
223+
(storage/save-document storage {:id key :value value})
224+
(when index (index/index-document index {:id key :value value}))
225+
(write-integer writer 1)))))))
181226

182227
(defn handle-exists [storage writer args]
183228
(if (empty? args)
@@ -197,8 +242,10 @@
197242
doc {:id hash-key :value value :hash-key key :hash-field field}
198243
existing-doc (storage/get-document storage hash-key)
199244
is-new (nil? existing-doc)]
200-
(storage/save-document storage doc)
201-
(write-integer writer (if is-new 1 0)))))
245+
(execute-redis-write storage "HSET" args {:metadata {:key key :field field}}
246+
(fn []
247+
(storage/save-document storage doc)
248+
(write-integer writer (if is-new 1 0)))))))
202249

203250
(defn handle-hget [storage writer args]
204251
(if (< (count args) 2)
@@ -218,12 +265,14 @@
218265
field-values (rest args)]
219266
(if (odd? (count field-values))
220267
(write-error writer "ERR wrong number of arguments for 'hmset' command")
221-
(do
222-
(doseq [[field value] (partition 2 field-values)]
223-
(let [hash-key (str key ":" field)
224-
doc {:id hash-key :value value :hash-key key :hash-field field}]
225-
(storage/save-document storage doc)))
226-
(.write writer RESP_OK))))))
268+
(let [field-count (/ (count field-values) 2)]
269+
(execute-redis-write storage "HMSET" args {:metadata {:key key :field-count field-count}}
270+
(fn []
271+
(doseq [[field value] (partition 2 field-values)]
272+
(let [hash-key (str key ":" field)
273+
doc {:id hash-key :value value :hash-key key :hash-field field}]
274+
(storage/save-document storage doc)))
275+
(.write writer RESP_OK))))))))
227276

228277
(defn handle-hmget [storage writer args]
229278
(if (< (count args) 2)
@@ -263,8 +312,10 @@
263312
{:id key :type "list" :values []})
264313
updated-values (vec (concat values (:values list-doc)))
265314
updated-doc (assoc list-doc :values updated-values)]
266-
(storage/save-document storage updated-doc)
267-
(write-integer writer (count updated-values)))))
315+
(execute-redis-write storage "LPUSH" args {:metadata {:key key :value-count (count values)}}
316+
(fn []
317+
(storage/save-document storage updated-doc)
318+
(write-integer writer (count updated-values)))))))
268319

269320
(defn handle-rpush [storage writer args]
270321
(if (< (count args) 2)
@@ -275,8 +326,10 @@
275326
{:id key :type "list" :values []})
276327
updated-values (vec (concat (:values list-doc) values))
277328
updated-doc (assoc list-doc :values updated-values)]
278-
(storage/save-document storage updated-doc)
279-
(write-integer writer (count updated-values)))))
329+
(execute-redis-write storage "RPUSH" args {:metadata {:key key :value-count (count values)}}
330+
(fn []
331+
(storage/save-document storage updated-doc)
332+
(write-integer writer (count updated-values)))))))
280333

281334
(defn handle-lrange [storage writer args]
282335
(if (not= (count args) 3)
@@ -313,8 +366,10 @@
313366
first-value (first values)
314367
updated-values (vec (rest values))
315368
updated-doc (assoc list-doc :values updated-values)]
316-
(storage/save-document storage updated-doc)
317-
(write-bulk-string writer first-value))
369+
(execute-redis-write storage "LPOP" args {:metadata {:key key}}
370+
(fn []
371+
(storage/save-document storage updated-doc)
372+
(write-bulk-string writer first-value))))
318373
(write-bulk-string writer nil)))))
319374

320375
(defn handle-rpop [storage writer args]
@@ -327,8 +382,10 @@
327382
last-value (last values)
328383
updated-values (vec (butlast values))
329384
updated-doc (assoc list-doc :values updated-values)]
330-
(storage/save-document storage updated-doc)
331-
(write-bulk-string writer last-value))
385+
(execute-redis-write storage "RPOP" args {:metadata {:key key}}
386+
(fn []
387+
(storage/save-document storage updated-doc)
388+
(write-bulk-string writer last-value))))
332389
(write-bulk-string writer nil)))))
333390

334391
(defn handle-llen [storage writer args]
@@ -351,8 +408,10 @@
351408
new-members (filter #(not (contains? existing-members %)) members)
352409
updated-members (apply conj existing-members members)
353410
updated-doc (assoc set-doc :members updated-members)]
354-
(storage/save-document storage updated-doc)
355-
(write-integer writer (count new-members)))))
411+
(execute-redis-write storage "SADD" args {:metadata {:key key :member-count (count members)}}
412+
(fn []
413+
(storage/save-document storage updated-doc)
414+
(write-integer writer (count new-members)))))))
356415

357416
(defn handle-smembers [storage writer args]
358417
(if (empty? args)
@@ -384,8 +443,11 @@
384443
removed-members (filter #(contains? existing-members %) members)
385444
updated-members (apply disj existing-members members)
386445
updated-doc (assoc set-doc :members updated-members)]
387-
(storage/save-document storage updated-doc)
388-
(write-integer writer (count removed-members)))
446+
(execute-redis-write storage "SREM" args {:metadata {:key key :member-count (count members)}
447+
:flags ["delete"]}
448+
(fn []
449+
(storage/save-document storage updated-doc)
450+
(write-integer writer (count removed-members)))))
389451
(write-integer writer 0)))))
390452

391453
;; Sorted Set commands
@@ -405,8 +467,10 @@
405467
existing-members
406468
score-member-pairs)
407469
updated-doc (assoc zset-doc :members updated-members)]
408-
(storage/save-document storage updated-doc)
409-
(write-integer writer new-members))))
470+
(execute-redis-write storage "ZADD" args {:metadata {:key key :member-count (count score-member-pairs)}}
471+
(fn []
472+
(storage/save-document storage updated-doc)
473+
(write-integer writer new-members))))))
410474

411475
(defn handle-zrange [storage writer args]
412476
(if (< (count args) 3)
@@ -471,8 +535,11 @@
471535
removed-count (count (filter #(contains? existing-members %) members))
472536
updated-members (apply dissoc existing-members members)
473537
updated-doc (assoc zset-doc :members updated-members)]
474-
(storage/save-document storage updated-doc)
475-
(write-integer writer removed-count))
538+
(execute-redis-write storage "ZREM" args {:metadata {:key key :member-count (count members)}
539+
:flags ["delete"]}
540+
(fn []
541+
(storage/save-document storage updated-doc)
542+
(write-integer writer removed-count))))
476543
(write-integer writer 0)))))
477544

478545
(defn handle-search

0 commit comments

Comments
 (0)