diff --git a/README.md b/README.md index 18491e8..8576253 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ Use `materialize` to convert a nested `XITDB` data structure to a native Clojure (xdb/materialize (get-in @db [:users "alice"])) ;; => {:name "Alice" :age 31} ``` +A value read from a database is a pointer into that database's storage, so it can +be written back into the *same* database without copying (this is what makes +reverting to an earlier version cheap, see History below), but it cannot be +written into a *different* database. Doing so throws an `IllegalArgumentException`; +`materialize` the value first to copy it: + +```clojure +(reset! other-db (xdb/materialize (get @db :users))) +``` + ## No query language Use `filter`, `group-by`, `reduce`, etc. diff --git a/deps.edn b/deps.edn index 88cd759..d860168 100644 --- a/deps.edn +++ b/deps.edn @@ -1,6 +1,6 @@ {:paths ["src" "test"] :deps {org.clojure/clojure {:mvn/version "1.12.0"} - io.github.radarroark/xitdb {:mvn/version "0.34.0"}} + io.github.radarroark/xitdb {:mvn/version "0.36.0"}} :aliases {:test {:extra-deps {io.github.cognitect-labs/test-runner diff --git a/src/xitdb/common.clj b/src/xitdb/common.clj index 8a34780..bba07e2 100644 --- a/src/xitdb/common.clj +++ b/src/xitdb/common.clj @@ -27,9 +27,18 @@ (seq? v) (doall (map materialize v)) :else v)) +(def ^:private ^Class unwrap-interface (:on-interface IUnwrap)) + +(defn wrapper? + "True for the XITDB* wrapper types, which all implement `IUnwrap` inline. + An interface check rather than `satisfies?`, which costs microseconds per + call on non-implementing classes and sits on the per-element write path." + [v] + (instance? unwrap-interface v)) + (defn unwrap "For a value that wraps another value, returns the wrapped value." [v] - (if (satisfies? IUnwrap v) + (if (wrapper? v) (-unwrap v) - v)) \ No newline at end of file + v)) diff --git a/src/xitdb/db.clj b/src/xitdb/db.clj index 05f3e55..f12bad4 100644 --- a/src/xitdb/db.clj +++ b/src/xitdb/db.clj @@ -2,11 +2,12 @@ (:require [xitdb.common :as common] [xitdb.util.conversion :as conversion] + [xitdb.util.db-context :as db-context] [xitdb.xitdb-types :as xtypes]) (:import [io.github.radarroark.xitdb - Core CoreBufferedFile CoreMemory Database Database$ContextFunction Hasher - RandomAccessBufferedFile RandomAccessMemory ReadArrayList WriteArrayList WriteCursor] + Core CoreBufferedFile CoreMemory CoreReadOnlyFile Database Database$ContextFunction Hasher + RandomAccessBufferedFile RandomAccessMemory ReadArrayList ReadCursor WriteArrayList WriteCursor] [java.io File] [java.nio.file Files] [java.nio.file.attribute FileAttribute] @@ -51,26 +52,20 @@ nil))) (.count history)) -(defn- write-value! [^WriteCursor cursor new-value] - (if (satisfies? common/ISlot new-value) - (.write cursor (common/-slot new-value)) - (.write cursor (conversion/v->slot! cursor new-value)))) - (defn xitdb-reset! "Sets the value of the database to `new-value`. Returns new history index." [^WriteArrayList history new-value] (append-context! history nil (fn [^WriteCursor cursor] - (write-value! cursor new-value)))) + (.write cursor (conversion/v->slot! cursor new-value))))) (defn v->slot! "Converts a value to a slot which can be written to a cursor. - For XITDB* types (which support ISlot), will return `-slot`, - for all other types `conversion/v->slot!`" + XITDB* types are written by reference (structural sharing) when they belong + to the same database as `cursor`; values from another database are refused. + All other types are converted by `conversion/v->slot!`." [^WriteCursor cursor v] - (if (satisfies? common/ISlot v) - (common/-slot v) - (conversion/v->slot! cursor v))) + (conversion/v->slot! cursor v)) (defn xitdb-swap! "Starts a new transaction and calls `f` with the value at `base-keypath`. @@ -81,15 +76,17 @@ Returns the transaction history index." [db base-keypath f & args] (let [history (db-history db) - slot (.getSlot history -1)] + slot (.getSlot history -1)] (append-context! history slot - (fn [^WriteCursor cursor] - (let [cursor (conversion/keypath-cursor cursor base-keypath) - obj (xtypes/read-from-cursor cursor true)] - (let [retval (apply f (into [obj] args))] - (write-value! cursor retval))))))) + (fn [^WriteCursor root-cursor] + (let [cursor (conversion/keypath-cursor root-cursor base-keypath) + obj (xtypes/read-from-cursor cursor true) + retval (apply f (into [obj] args)) + ;; the callback may have frozen the original destination + cursor (conversion/keypath-cursor root-cursor base-keypath)] + (.write cursor (conversion/v->slot! cursor retval))))))) (defn xitdb-swap-with-lock! "Performs the 'swap!' operation while locking `db.lock`. @@ -120,6 +117,14 @@ [^Database db] (.close ^Core (.-core db))) +(defn- close-after-failure! + "Closes a core without replacing the failure that triggered cleanup." + [^Core core ^Throwable failure] + (try + (.close core) + (catch Throwable close-error + (.addSuppressed failure close-error)))) + (defn ^ReadArrayList read-history "Returns the read only transaction history array." @@ -131,15 +136,15 @@ (defn deref-at "Returns the version of the data at the specified index." [xdb index] - (let [history (read-history (-> xdb .tldbro .get)) + (let [history (read-history (.-rodb xdb)) cursor (.getCursor history index)] (xtypes/read-from-cursor cursor false))) -(deftype XITDBDatabase [tldbro rwdb lock] +(deftype XITDBDatabase [rodb rwdb lock] java.io.Closeable (close [this] - (close-db-internal! (.get tldbro)) + (close-db-internal! rodb) (close-db-internal! rwdb)) clojure.lang.IDeref @@ -148,7 +153,7 @@ clojure.lang.Counted (count [this] - (.count (read-history (.get tldbro)))) + (.count (read-history rodb))) clojure.lang.IAtom @@ -177,26 +182,44 @@ (swap [this f x y args] (apply xitdb-swap-with-lock! (concat [this nil f x y] args)))) -(defn- wrap-db [filename ^Database rwdb] - (if (= :memory filename) - (let [tdbmem (proxy [ThreadLocal] [] - (initialValue [] - rwdb))] - (->XITDBDatabase tdbmem rwdb (ReentrantLock.))) - - (let [tldb (proxy [ThreadLocal] [] - (initialValue [] - (open-database filename "r")))] - (->XITDBDatabase tldb rwdb (ReentrantLock.))))) +(defn- wrap-db + "Wraps writer handle `rwdb` into an XITDBDatabase, opening its reader handle. + + Reads go through one shared read-only handle, so a value read on one thread + can be used from any other. The engine's read path only touches the handle's + Core and its immutable header: the in-memory Core keeps a per-thread position + and `CoreReadOnlyFile` keeps a per-thread file handle, and key hashing + uses independent engine digests (see `conversion/db-key-hash`). Writes go through + `rwdb` under the lock. + + The writer refers to its reader so values from that reader are accepted + when written back and values from other databases are not." + [filename ^Database rwdb] + (let [^Core ro-core (if (= :memory filename) + (.-core rwdb) + (CoreReadOnlyFile. (File. ^String filename))) + context (try + (db-context/create ro-core (.-core rwdb) (.hasher rwdb)) + (catch Throwable t + (when-not (= :memory filename) + (close-after-failure! ro-core t)) + (throw t)))] + (->XITDBDatabase (:reader context) (:writer context) (ReentrantLock.)))) (defn xit-db "Returns a new XITDBDatabase which can be used to query and transact data. `filename` can be `:memory` or the name of a file on the filesystem. If the file does not exist, it will be created. - The returned database handle can be used from multiple threads. - Reads can run in parallel, transactions (eg. `swap!`) will only allow one writer at a time." + The returned database handle can be used from multiple threads, and so can the + values read from it. Reads can run in parallel, transactions (eg. `swap!`) will + only allow one writer at a time." [filename] - (wrap-db filename (open-database filename "rw"))) + (let [^Database writer (open-database filename "rw")] + (try + (wrap-db filename writer) + (catch Throwable t + (close-after-failure! (.-core writer) t) + (throw t))))) (defn- create-compact-target [filename] (if (= :memory filename) @@ -225,21 +248,14 @@ (throw (IllegalStateException. "compact should not be called from swap! or reset!"))) (try (.lock lock) - (let [target-info (create-compact-target target) + (let [target-info (create-compact-target target) ^Core target-core (:core target-info)] (try (let [compacted (.compact ^Database (.-rwdb xdb) target-core)] - ;; xitdb 0.34.0 shares the source's mutable digest with the copy. - ;; These handles have independent locks, so their digests must too. - (set! (.-md compacted) - (MessageDigest/getInstance (.getAlgorithm (.-md compacted)))) (wrap-db target compacted)) (catch Throwable t ;; Clean up the target without hiding the original error - (try - (.close target-core) - (catch Throwable close-error - (.addSuppressed ^Throwable t close-error))) + (close-after-failure! target-core t) (when-let [^File file (:file target-info)] (try (Files/deleteIfExists (.toPath file)) @@ -266,13 +282,13 @@ (xitdb-swap-with-lock! xdb keypath (constantly new-value))) (swap [this f] - (xitdb-swap-with-lock! xdb keypath f)) + (xitdb-swap-with-lock! xdb keypath f)) (swap [this f a] - (xitdb-swap-with-lock! xdb keypath f a)) + (xitdb-swap-with-lock! xdb keypath f a)) (swap [this f a1 a2] - (xitdb-swap-with-lock! xdb keypath f a1 a2)) + (xitdb-swap-with-lock! xdb keypath f a1 a2)) (swap [this f x y args] (apply xitdb-swap-with-lock! (concat [xdb keypath f x y] args)))) @@ -296,6 +312,10 @@ [x] (when-not (satisfies? common/IReadOnly x) (throw (IllegalArgumentException. - (str "freeze! requires a writeable XITDB data structure, got: " (type x))))) - (-> x common/-unwrap .cursor .db .freeze) - (common/-read-only x)) + (str "freeze! requires a writeable XITDB data structure, got: " (type x))))) + (let [^ReadCursor cursor (-> x common/-unwrap .cursor) + ^Database writer (.-db cursor) + ^Database reader (db-context/reader-database writer)] + (.freeze writer) + (.flush ^Core (.-core writer)) + (xtypes/read-from-cursor (ReadCursor. (.-slotPtr cursor) reader) false))) diff --git a/src/xitdb/hash_map.clj b/src/xitdb/hash_map.clj index db90b98..bf5e2ee 100644 --- a/src/xitdb/hash_map.clj +++ b/src/xitdb/hash_map.clj @@ -92,7 +92,7 @@ common/IMaterialize (-materialize [this] (reduce (fn [m [k v]] - (assoc m k (common/materialize v))) {} (seq this))) + (assoc m (common/materialize k) (common/materialize v))) {} (seq this))) common/IMaterializeShallow (-materialize-shallow [this] diff --git a/src/xitdb/linked_list.clj b/src/xitdb/linked_list.clj index 163335c..ded722a 100644 --- a/src/xitdb/linked_list.clj +++ b/src/xitdb/linked_list.clj @@ -76,7 +76,7 @@ (to-array (into [] this))) (^objects toArray [this ^objects array] - (let [len (count this) + (let [len (count this) ^objects result (if (or (nil? array) (< (alength array) len)) (make-array Object len) array)] @@ -97,14 +97,14 @@ common/IMaterialize (-materialize [this] (apply list - (reduce (fn [a v] - (conj a (common/materialize v))) [] (seq this)))) + (reduce (fn [a v] + (conj a (common/materialize v))) [] (seq this)))) common/IMaterializeShallow (-materialize-shallow [this] (apply list - (reduce (fn [a v] - (conj a v)) [] (seq this)))) + (reduce (fn [a v] + (conj a v)) [] (seq this)))) Object (toString [this] diff --git a/src/xitdb/snapshot.clj b/src/xitdb/snapshot.clj index f9e300e..e6a98fc 100644 --- a/src/xitdb/snapshot.clj +++ b/src/xitdb/snapshot.clj @@ -10,9 +10,12 @@ (defn snapshot-memory-db "Returns a memory database with the value of `keypath` in the database at `filename` When keypath is [], returns a memdb with all the data in the db `filename`. - Useful for REPL-based investigation and testing." + Useful for REPL-based investigation and testing. + + The value read from `filename` is a pointer into that file, so it is + materialized (copied) before being written into the memory database." [filename keypath] (with-open [db (xit-db-existing filename)] (let [memdb (xdb/xit-db :memory)] - (reset! memdb (get-in @db keypath)) + (reset! memdb (xdb/materialize (get-in @db keypath))) memdb))) \ No newline at end of file diff --git a/src/xitdb/sorted_map.clj b/src/xitdb/sorted_map.clj index 14ea8fc..1b77351 100644 --- a/src/xitdb/sorted_map.clj +++ b/src/xitdb/sorted_map.clj @@ -139,7 +139,7 @@ common/IMaterialize (-materialize [this] (reduce (fn [m [k v]] - (assoc m k (common/materialize v))) (sorted-map-by sorted-key/key-comparator) (seq this))) + (assoc m (common/materialize k) (common/materialize v))) (sorted-map-by sorted-key/key-comparator) (seq this))) common/IMaterializeShallow (-materialize-shallow [this] diff --git a/src/xitdb/util/conversion.clj b/src/xitdb/util/conversion.clj index 1ec81cd..5332275 100644 --- a/src/xitdb/util/conversion.clj +++ b/src/xitdb/util/conversion.clj @@ -1,13 +1,16 @@ (ns xitdb.util.conversion (:require + [xitdb.common :as common] + [xitdb.util.db-context :as db-context] [xitdb.util.sorted-key :as sorted-key] [xitdb.util.validation :as validation]) (:import [clojure.lang PersistentTreeMap PersistentTreeSet] [io.github.radarroark.xitdb - Database Database$Bytes Database$Float Database$Int - ReadCursor Slot Slotted Tag WriteArrayList WriteCountedHashMap WriteCountedHashSet WriteCursor - WriteHashMap WriteHashSet WriteLinkedArrayList WriteSortedMap WriteSortedSet] + Database Database$Bytes Database$Float Database$Int Database$HashFunction + ReadArrayList ReadCursor ReadHashMap ReadHashSet ReadLinkedArrayList ReadSortedMap ReadSortedSet + Slot Slotted Tag WriteArrayList WriteCountedHashMap WriteCountedHashSet WriteCursor + WriteHashMap WriteLinkedArrayList WriteSortedMap WriteSortedSet] [java.io OutputStream OutputStreamWriter] [java.security DigestOutputStream])) @@ -71,37 +74,36 @@ key)) (defn db-key-hash - "Returns a byte array representing the stable hash digest of (Clojure) value `v`. - Uses the MessageDigest from the database." + "Returns the stable hash of value `v`, using an independent engine digest." ^bytes [^Database jdb v] (if (nil? v) - (byte-array (-> jdb .md .getDigestLength)) - (let [digest (.md jdb) - fmt-tag (or (some-> v fmt-tag-keyword fmt-tag-value) + (byte-array (.hashSize (.-header jdb))) + (let [fmt-tag (or (some-> v fmt-tag-keyword fmt-tag-value) (throw (IllegalArgumentException. (str "Unsupported key type: " (type v)))))] - ;; add format tag - (.update digest (.getBytes fmt-tag "UTF-8")) - ;; add the value - (cond - (validation/lazy-seq? v) - (throw (IllegalArgumentException. "Lazy sequences can be infinite and not allowed!")) - - (bytes? v) - (.update digest v) - - (instance? Database$Bytes v) - (.update digest (.value v)) - - (coll? v) - (with-open [os (DigestOutputStream. (OutputStream/nullOutputStream) digest)] - (with-open [writer (OutputStreamWriter. os)] - (binding [*out* writer] - (pr v)))) - - :else - (.update digest (.getBytes (str v) "UTF-8"))) - ;; finish hash - (.digest digest)))) + (.hash jdb + (reify Database$HashFunction + (update [_ digest] + ;; add format tag + (.update digest (.getBytes fmt-tag "UTF-8")) + ;; add the value + (cond + (validation/lazy-seq? v) + (throw (IllegalArgumentException. "Lazy sequences can be infinite and not allowed!")) + + (bytes? v) + (.update digest v) + + (instance? Database$Bytes v) + (.update digest (.value v)) + + (coll? v) + (with-open [os (DigestOutputStream. (OutputStream/nullOutputStream) digest)] + (with-open [writer (OutputStreamWriter. os)] + (binding [*out* writer] + (pr v)))) + + :else + (.update digest (.getBytes (str v) "UTF-8"))))))))) (defn ^Slot primitive-for "Converts a Clojure primitive value to its corresponding XitDB representation. @@ -161,19 +163,53 @@ (or (identical? clojure.lang.RT/DEFAULT_COMPARATOR cmp) (identical? sorted-key/key-comparator cmp)))) +(defn- ^Database slotted-database + "The engine handle a Slotted value was read through, or nil when unknown." + [v] + (cond + (instance? ReadCursor v) (.-db ^ReadCursor v) + (instance? ReadHashMap v) (.-db ^ReadCursor (.-cursor ^ReadHashMap v)) + (instance? ReadHashSet v) (.-db ^ReadCursor (.-cursor ^ReadHashSet v)) + (instance? ReadArrayList v) (.-db ^ReadCursor (.-cursor ^ReadArrayList v)) + (instance? ReadLinkedArrayList v) (.-db ^ReadCursor (.-cursor ^ReadLinkedArrayList v)) + (instance? ReadSortedMap v) (.-db ^ReadCursor (.-cursor ^ReadSortedMap v)) + (instance? ReadSortedSet v) (.-db ^ReadCursor (.-cursor ^ReadSortedSet v)))) + +(defn- ^Slot slot-of! + "The slot of Slotted `v`, for writing through `cursor`. A slot is an offset + into the storage of the database it was read from, so it is only meaningful + in that same database; anything else is refused before it can be committed." + [^WriteCursor cursor ^Slotted v] + (when-let [source-db (slotted-database v)] + (when-not (db-context/same-database? (.-db cursor) source-db) + (throw (IllegalArgumentException. + (str "Cannot write a value that belongs to a different database. " + "Values read from an xitdb database are pointers into its storage; " + "call xitdb.db/materialize on the value first to copy it."))))) + (.slot v)) + (defn ^Slot v->slot! "Converts a value to a XitDB slot. Handles WriteArrayList and WriteHashMap instances directly. Recursively processes Clojure maps and collections. - Falls back to primitive conversion for other types." + Falls back to primitive conversion for other types. + + Branch order matters. XITDB* wrapper types are also `map?`/`set?`/ + `sequential?`, so they are unwrapped to the underlying Slotted first or they + would be deep-copied through the generic branches (and a sorted map/set would + come back as a hash one). Likewise a sorted map/set is checked before the + generic hash-map/hash-set branch that would otherwise shadow it." [^WriteCursor cursor v] (cond (validation/lazy-seq? v) (throw (IllegalArgumentException. "Lazy sequences can be infinite and not allowed!")) + (common/wrapper? v) + (v->slot! cursor (common/-unwrap v)) + (instance? Slotted v) - (.slot ^Slotted v) + (slot-of! cursor v) ;; A sorted map is also `map?`, so it MUST be checked before the generic ;; hash-map branch or it would be shadowed and stored as a hash map. @@ -225,15 +261,20 @@ (defn ^WriteCursor coll->ArrayListCursor! "Converts a Clojure collection to a XitDB ArrayList cursor. Handles nested maps and collections recursively. - Returns the cursor of the created WriteArrayList." + Returns the cursor of the created WriteArrayList. + + Sorted maps/sets and XITDB wrappers are also `map?`/`set?`/`sequential?`, + so they are delegated to `v->slot!` (which checks those types first) before + the generic hash branches." [^WriteCursor cursor coll] (when *debug?* (println "Write array" (type coll))) (let [write-array (WriteArrayList. cursor)] (doseq [v coll] (cond - ;; Sorted map/set are also map?/set?, so delegate to v->slot! (which - ;; checks the tree types first) before the generic hash branches. - (or (instance? PersistentTreeMap v) (instance? PersistentTreeSet v)) + (or (common/wrapper? v) + (instance? Slotted v) + (instance? PersistentTreeMap v) + (instance? PersistentTreeSet v)) (let [v-cursor (.appendCursor write-array)] (.write v-cursor (v->slot! v-cursor v))) @@ -260,16 +301,21 @@ (defn ^WriteCursor list->LinkedArrayListCursor! "Converts a Clojure list or seq-like collection to a XitDB LinkedArrayList cursor. - Optimized for sequential access collections rather than random access ones." + Optimized for sequential access collections rather than random access ones. + + Sorted maps/sets and XITDB wrappers are also `map?`/`set?`/`sequential?`, + so they are delegated to `v->slot!` (which checks those types first) before + the generic hash branches." [^WriteCursor cursor coll] (when *debug?* (println "Write list" (type coll))) (let [write-list (WriteLinkedArrayList. cursor)] (doseq [v coll] (when *debug?* (println "v=" v)) (cond - ;; Sorted map/set are also map?/set?, so delegate to v->slot! (which - ;; checks the tree types first) before the generic hash branches. - (or (instance? PersistentTreeMap v) (instance? PersistentTreeSet v)) + (or (common/wrapper? v) + (instance? Slotted v) + (instance? PersistentTreeMap v) + (instance? PersistentTreeSet v)) (let [v-cursor (.appendCursor write-list)] (.write v-cursor (v->slot! v-cursor v))) @@ -379,11 +425,6 @@ :else str))) -(defn set-write-cursor - [^WriteHashSet whs key] - (let [hash-code (db-key-hash (-> whs .-cursor .-db) key)] - (.putCursor whs hash-code))) - (defn map-write-cursor "Gets a write cursor for the specified key in a WriteHashMap. Creates the key if it doesn't exist." @@ -391,6 +432,16 @@ (let [key-hash (db-key-hash (-> whm .cursor .db) key)] (.putCursor whm key-hash))) +(defn map-write-cursor-storing-key! + "Like `map-write-cursor`, but also stores `key` itself when the entry is + new. The hash-only `putCursor` never writes the key, so without this a + keypath write to an absent key would leave a keyless entry behind." + [^WriteHashMap whm key] + (let [key-hash (db-key-hash (-> whm .cursor .db) key) + key-cursor (.putKeyCursor whm key-hash)] + (.writeIfEmpty key-cursor (v->slot! key-cursor key)) + (.putCursor whm key-hash))) + (defn array-list-write-cursor "Returns a cursor to slot i in the array list. Throws if index is out of bounds." @@ -403,14 +454,24 @@ (validation/validate-index-bounds i (.count wlal) "Linked array list write cursor") (.putCursor wlal i)) -(defn write-cursor-for-key [cursor current-key] +(defn write-cursor-for-key + "Returns a write cursor to `current-key` inside the collection at `cursor`, + creating the entry when it is absent (for hash maps the key itself is stored + too, see `map-write-cursor-storing-key!`). + + Sets have no member cursor: a set member is its own key. A hash-set member + lives under the hash of its value and a sorted-set member is an immutable + B-tree key, so writing a different value through a member cursor would leave + the member filed under the wrong hash/key. Both throw IllegalArgumentException; + membership changes go through conj/disj on the set itself." + [cursor current-key] (let [value-tag (some-> cursor .slot .tag)] (cond (= value-tag Tag/HASH_MAP) - (map-write-cursor (WriteHashMap. cursor) current-key) + (map-write-cursor-storing-key! (WriteHashMap. cursor) current-key) (= value-tag Tag/COUNTED_HASH_MAP) - (map-write-cursor (WriteCountedHashMap. cursor) current-key) + (map-write-cursor-storing-key! (WriteCountedHashMap. cursor) current-key) ;; Sorted maps store the real key bytes (order-preserving codec), so a ;; keypath write resolves a value cursor by the encoded key, mirroring the @@ -418,13 +479,13 @@ (= value-tag Tag/SORTED_MAP) (.putCursor (WriteSortedMap. cursor) (sorted-key/encode-key current-key)) - (= value-tag Tag/HASH_SET) - (set-write-cursor (WriteHashSet. cursor) current-key) + (contains? #{Tag/HASH_SET Tag/COUNTED_HASH_SET} value-tag) + (throw (IllegalArgumentException. + (format (str "Cannot get a write cursor to set member '%s': " + "set members are immutable keys. Use conj/disj " + "on the set itself to change membership.") + current-key))) - ;; A sorted-set member is stored as an immutable B-tree key (the engine - ;; only exposes a writeable value slot, which a set never uses), so there - ;; is no in-place "member cursor" to hand back the way a hash set has. - ;; Mutating membership goes through conj/disj on the set itself. (= value-tag Tag/SORTED_SET) (throw (IllegalArgumentException. (format (str "Cannot get a write cursor to sorted-set member '%s': " @@ -432,9 +493,6 @@ "on the sorted set itself to change membership.") current-key))) - (= value-tag Tag/COUNTED_HASH_SET) - (set-write-cursor (WriteCountedHashSet. cursor) current-key) - (= value-tag Tag/ARRAY_LIST) (array-list-write-cursor (WriteArrayList. cursor) current-key) @@ -455,4 +513,4 @@ (let [new-cursor (write-cursor-for-key cursor current-key)] (if (empty? remaining-keys) new-cursor - (recur new-cursor remaining-keys)))))) \ No newline at end of file + (recur new-cursor remaining-keys)))))) diff --git a/src/xitdb/util/db_context.clj b/src/xitdb/util/db_context.clj new file mode 100644 index 0000000..99f954c --- /dev/null +++ b/src/xitdb/util/db_context.clj @@ -0,0 +1,30 @@ +(ns xitdb.util.db-context + "Reader and writer handles access the same storage but have different identities. + Linking them lets the wrapper reuse stored pointers within one database, reject + pointers from other databases, and route frozen values through the reader so + they can be shared across threads without using the writer's mutable file state." + (:import + [io.github.radarroark.xitdb Database])) + +(definterface DatabaseOwner + (readerDatabase [])) + +(defn create + "Creates a reader and a writer that refers directly to it." + [reader-core writer-core hasher] + (let [reader (Database. reader-core hasher) + writer (proxy [Database DatabaseOwner] [writer-core hasher] + (readerDatabase [] reader))] + {:reader reader :writer writer})) + +(defn reader-database + "Reader associated with a writer, or the handle itself." + [db] + (if (instance? DatabaseOwner db) + (.readerDatabase ^DatabaseOwner db) + db)) + +(defn same-database? + "True when handles share a reader, or are the same bare Java handle." + [db-a db-b] + (identical? (reader-database db-a) (reader-database db-b))) diff --git a/src/xitdb/util/operations.clj b/src/xitdb/util/operations.clj index 9b11e74..e3f7d47 100644 --- a/src/xitdb/util/operations.clj +++ b/src/xitdb/util/operations.clj @@ -119,10 +119,7 @@ Throws IllegalArgumentException if attempting to associate an internal key. Updates the internal count if fast counting is enabled." [^WriteHashMap whm k v] - (let [key-hash (conversion/db-key-hash (-> whm .cursor .db) k) - key-cursor (.putKeyCursor whm key-hash) - cursor (.putCursor whm key-hash)] - (.writeIfEmpty key-cursor (conversion/v->slot! key-cursor k)) + (let [cursor (conversion/map-write-cursor-storing-key! whm k)] (.write cursor (conversion/v->slot! cursor v)) whm)) diff --git a/src/xitdb/xitdb_types.clj b/src/xitdb/xitdb_types.clj index b011b11..86a9e28 100644 --- a/src/xitdb/xitdb_types.clj +++ b/src/xitdb/xitdb_types.clj @@ -9,7 +9,7 @@ [xitdb.sorted-set :as xsorted-set] [xitdb.util.conversion :as conversion]) (:import - [io.github.radarroark.xitdb ReadCursor Slot Tag WriteCursor])) + [io.github.radarroark.xitdb ReadCursor Tag WriteCursor])) (defn read-from-cursor "Reads the value at cursor and converts it to a Clojure type. @@ -85,13 +85,6 @@ (-read-from-cursor [this] (read-from-cursor this true))) -(defn ^Slot slot-for-value! [^WriteCursor cursor v] - (cond - (satisfies? common/ISlot v) - (common/-slot v) - :else - (conversion/v->slot! cursor v))) - (defn materialize "Converts a xitdb data structure `v` to a clojure data structure. This has the effect of reading the whole data structure into memory." diff --git a/test/xitdb/close_test.clj b/test/xitdb/close_test.clj new file mode 100644 index 0000000..64d580a --- /dev/null +++ b/test/xitdb/close_test.clj @@ -0,0 +1,137 @@ +(ns xitdb.close-test + "Closing a database releases every thread's reader handle, not only the + closing thread's." + (:require + [clojure.test :refer :all] + [xitdb.db :as xdb] + [xitdb.util.db-context :as db-context]) + (:import + [io.github.radarroark.xitdb CoreBufferedFile] + [java.io IOException])) + +(defn- temp-db-file [] + (let [f (java.io.File/createTempFile "xitdb-close" ".db")] + (.delete f) + (.deleteOnExit f) + (.getAbsolutePath f))) + +(deftest reader-open-failure-closes-writer + (let [file (temp-db-file) + writer (xdb/open-database file "rw") + core (.-core writer)] + (try + ;; The writer is open, but the reader's path cannot be opened because + ;; its parent is a regular file. This fails before context creation. + (with-redefs [xdb/open-database (fn [& _] writer)] + (is (thrown? IOException (xdb/xit-db (str file "/missing.db"))))) + (is (thrown? IOException (.sync core)) "the writer descriptor was closed") + (finally + (.close core) + (.delete (java.io.File. file)))))) + +(deftest context-creation-failure-closes-reader-and-writer + (let [file (temp-db-file) + writer (xdb/open-database file "rw") + core (.-core writer) + reader (atom nil) + failure (IOException. "context initialization failed")] + (try + (with-redefs [xdb/open-database (fn [& _] writer) + db-context/create (fn [reader-core & _] + (reset! reader reader-core) + (throw failure))] + (is (identical? failure + (try (xdb/xit-db file) (catch IOException t t))))) + (is (thrown? IllegalStateException (.length @reader)) "the reader was closed") + (is (thrown? IOException (.sync core)) "the writer descriptor was closed") + (finally + (when @reader (.close @reader)) + (.close core) + (.delete (java.io.File. file)))))) + +(deftest initialization-failure-survives-writer-close-failure + (let [file (temp-db-file) + writer (xdb/open-database file "rw") + core (.-core writer) + failure (IOException. "context initialization failed") + close-failure (IOException. "writer close failed")] + (set! (.-core writer) + (proxy [CoreBufferedFile] [(.-file ^CoreBufferedFile core)] + (close [] + (proxy-super close) + (throw close-failure)))) + (try + (with-redefs [xdb/open-database (fn [& _] writer) + db-context/create (fn [& _] (throw failure))] + (is (identical? failure + (try (xdb/xit-db file) (catch IOException t t))))) + (is (= [close-failure] (vec (.getSuppressed failure)))) + (is (thrown? IOException (.sync core)) "cleanup still closed the writer") + (finally + (.close core) + (.delete (java.io.File. file)))))) + +(defn- on-new-thread + "Runs `f` on a fresh (non-pooled) thread and returns its result or the + Throwable it threw." + [f] + (let [result (promise) + thread (Thread. (fn [] (deliver result (try (f) (catch Throwable t t)))))] + (.start thread) + (.join thread 5000) + (when (.isAlive thread) + (.interrupt thread) + (throw (ex-info "Reader thread did not finish" {}))) + @result)) + +(defn- collected-within? + "Requests collection until `pred` holds, allowing time for file cleaners." + [pred] + (let [deadline (+ (System/nanoTime) 5000000000)] + (loop [] + (System/gc) + (Thread/sleep 50) + (cond + (pred) true + (< (System/nanoTime) deadline) (recur) + :else false)))) + +(deftest terminated-reader-threads-do-not-retain-file-handles + (let [os (java.lang.management.ManagementFactory/getOperatingSystemMXBean)] + (when (instance? com.sun.management.UnixOperatingSystemMXBean os) + (let [file (temp-db-file) + fds #(.getOpenFileDescriptorCount ^com.sun.management.UnixOperatingSystemMXBean os)] + (try + (with-open [db (xdb/xit-db file)] + (reset! db {:a 1}) + ;; Clear handles left for collection by earlier tests before measuring. + (collected-within? (constantly true)) + (let [before (fds)] + (dotimes [_ 32] + (is (= 1 (on-new-thread #(get @db :a))))) + (is (collected-within? #(<= (fds) before)) + "terminated threads' descriptors are reclaimed while the database stays open") + (is (= 1 (get @db :a)) "the live reader is still usable"))) + (finally + (.delete (java.io.File. file)))))))) + +(deftest close-releases-reader-handles-of-other-threads + (let [file (temp-db-file) + db (xdb/xit-db file)] + (reset! db {:a 1}) + (let [held (promise) + go (promise) + after (promise) + reader (Thread. (fn [] + (deliver held @db) + @go + (deliver after (try (get @held :a) (catch Throwable t t)))))] + (.start reader) + (is (= 1 (get @held :a)) "the worker thread read through its own handle") + (.close db) + (deliver go true) + (.join reader) + (is (instance? IllegalStateException @after) + "the worker's handle was closed by the main thread's close") + (is (instance? IllegalStateException (on-new-thread #(deref db))) + "a thread that first touches the database after close gets a clear error, not a new handle")))) diff --git a/test/xitdb/compaction_test.clj b/test/xitdb/compaction_test.clj index 183093d..62d4765 100644 --- a/test/xitdb/compaction_test.clj +++ b/test/xitdb/compaction_test.clj @@ -213,38 +213,15 @@ (deftest compact-source-and-target-hash-independently-test (with-open [source (xdb/xit-db :memory)] (reset! source {}) - (let [delegate (MessageDigest/getInstance "SHA-1") - pause-once? (atom true) - hashing-started (promise) - target-written (promise) - pause (fn [] - (when (compare-and-set! pause-once? true false) - (deliver hashing-started true) - (when (= ::timeout (deref target-written 5000 ::timeout)) - (throw (ex-info "Timed out waiting for target write" {}))))) - digest (proxy [MessageDigest] ["SHA-1"] - (engineGetDigestLength [] 20) - (engineUpdate - ([b] (.update delegate (byte b)) (pause)) - ([b offset length] (.update delegate b offset length) (pause))) - (engineDigest [] (.digest delegate)) - (engineReset [] (.reset delegate)))] - ;; Pause a source write midway through hashing its key. A target write - ;; must not consume or reset that partial hash, even though locks differ. - (set! (.-md (.-rwdb source)) digest) - (with-open [compacted (xdb/compact source :memory)] - (let [writer (future (reset! source {:left 1}))] - (try - (is (= true (deref hashing-started 5000 ::timeout))) - (reset! compacted {:right 2}) - (finally - (deliver target-written true))) - (try - (is (not= ::timeout (deref writer 5000 ::timeout))) - (is (= 1 (get @source :left))) - (is (= 2 (get @compacted :right))) - (finally - (future-cancel writer)))))))) + (with-open [compacted (xdb/compact source :memory)] + (let [n-writes 200 + writer (future (dotimes [i n-writes] (swap! source assoc (str "left-" i) i)))] + (dotimes [i n-writes] (swap! compacted assoc (str "right-" i) i)) + (is (not= ::timeout (deref writer 10000 ::timeout))) + (is (= n-writes (count @source))) + (is (= n-writes (count @compacted))) + (is (every? #(= % (get @source (str "left-" %))) (range n-writes))) + (is (every? #(= % (get @compacted (str "right-" %))) (range n-writes))))))) (deftest compact-cleans-up-failed-copy-test (let [source-path (new-path) diff --git a/test/xitdb/cross_database_test.clj b/test/xitdb/cross_database_test.clj new file mode 100644 index 0000000..f81fbaf --- /dev/null +++ b/test/xitdb/cross_database_test.clj @@ -0,0 +1,68 @@ +(ns xitdb.cross-database-test + "A value read from one database is a pointer into that database's storage. + Writing it into a different database must be refused up front instead of + committing a dangling pointer." + (:require + [clojure.test :refer :all] + [xitdb.db :as xdb])) + +(deftest reset-with-value-from-another-database-is-refused + (with-open [source (xdb/xit-db :memory) + target (xdb/xit-db :memory)] + (reset! source {:payload [1 2 3]}) + (reset! target {:ok true}) + (let [ex (is (thrown? IllegalArgumentException (reset! target (get @source :payload))))] + (is (re-find #"materialize" (.getMessage ex)) "the error tells the caller how to copy the value")) + (is (= 1 (count target)) "nothing was committed") + (is (= {:ok true} (xdb/materialize @target)) "the target is still readable"))) + +(defn- temp-db-file [] + (let [f (java.io.File/createTempFile "xitdb-cross" ".db")] + (.delete f) + (.deleteOnExit f) + (.getAbsolutePath f))) + +(deftest values-from-the-same-file-database-are-accepted + ;; A file database reads through a per-thread read-only handle and writes + ;; through a separate writer handle. Values must still be recognised as the + ;; database's own, whichever thread read them. + (with-open [db (xdb/xit-db (temp-db-file))] + (reset! db {:v [1 2]}) + (swap! db assoc :v [3]) + (testing "reverting to an earlier version writes a value read from history" + (reset! db (xdb/deref-at db 0)) + (is (= {:v [1 2]} (xdb/materialize @db)))) + (testing "a value read on another thread can be written on this one" + (let [read-elsewhere @(future (get @db :v))] + (swap! db assoc :copy read-elsewhere) + (is (= {:v [1 2] :copy [1 2]} (xdb/materialize @db))))))) + +(deftest foreign-value-nested-in-a-plain-collection-is-refused + (with-open [source (xdb/xit-db :memory) + target (xdb/xit-db :memory)] + (reset! source {:payload [1 2 3]}) + (reset! target {:ok true}) + (is (thrown? IllegalArgumentException + (swap! target assoc :imported {:wrapped (get @source :payload)}))) + (is (= 1 (count target)) "nothing was committed") + (is (= {:ok true} (xdb/materialize @target))))) + +(deftest materialized-foreign-value-is-accepted + (with-open [source (xdb/xit-db :memory) + target (xdb/xit-db :memory)] + (reset! source {:payload [1 2 3]}) + (reset! target (xdb/materialize @source)) + (is (= {:payload [1 2 3]} (xdb/materialize @target))))) + +(deftest materialized-value-with-collection-keys-is-accepted + ;; A collection key is read back as a database-backed value too, so + ;; materialize has to copy keys as well as values for the copy to be writable + ;; into another database. + (with-open [source (xdb/xit-db :memory) + target (xdb/xit-db :memory)] + (reset! source {[1 2] :v {:k 1} #{:s} :sorted (sorted-map 3 {[4] 5})}) + (let [m (xdb/materialize @source)] + (is (every? #(instance? clojure.lang.PersistentVector %) + [(-> m keys first) (-> m :sorted (get 3) keys first)])) + (reset! target m) + (is (= {[1 2] :v {:k 1} #{:s} :sorted {3 {[4] 5}}} (xdb/materialize @target)))))) diff --git a/test/xitdb/cursor_test.clj b/test/xitdb/cursor_test.clj index 9473093..232537b 100644 --- a/test/xitdb/cursor_test.clj +++ b/test/xitdb/cursor_test.clj @@ -59,3 +59,22 @@ (let [c (xdb/xdb-cursor db [:tags "a"]) ex (is (thrown? IllegalArgumentException (reset! c "z")))] (is (re-find #"sorted-set member" (.getMessage ex))))))) + +(deftest cursor-write-to-absent-map-key-stores-the-key + (with-open [db (xdb/xit-db :memory)] + (reset! db {:existing 1}) + (reset! (xdb/xdb-cursor db [:brand-new]) 42) + (testing "the new entry is a real key/value pair, not a keyless slot" + (is (= {:existing 1 :brand-new 42} (xdb/materialize @db))) + (is (= #{:existing :brand-new} (set (keys @db))))))) + +(deftest cursor-into-hash-set-member-is-rejected + (with-open [db (xdb/xit-db :memory)] + (reset! db {:tags #{:a :b}}) + (testing "a member is stored under its own hash, so overwriting it in place + would desync the set; the write is refused and nothing changes" + (let [c (xdb/xdb-cursor db [:tags :a]) + ex (is (thrown? IllegalArgumentException (reset! c :z)))] + (is (re-find #"set member" (.getMessage ex))) + (is (= #{:a :b} (xdb/materialize (get @db :tags)))) + (is (= 1 (count db)) "the refused write did not append a history entry"))))) diff --git a/test/xitdb/freeze_test.clj b/test/xitdb/freeze_test.clj index 075b3e0..a3ac5d6 100644 --- a/test/xitdb/freeze_test.clj +++ b/test/xitdb/freeze_test.clj @@ -5,6 +5,18 @@ [xitdb.db :as xdb] [xitdb.test-utils :as tu])) +(deftest cursor-freeze + ;; freezing makes the nested cursor unwritable; swap! must reacquire it + ;; from the transaction root before writing the callback's result + (with-open [db (xdb/xit-db :memory)] + (reset! db {:nested {:v 1}}) + (let [cursor (xdb/xdb-cursor db [:nested])] + (swap! cursor xdb/freeze!) + (is (= {:v 1} (xdb/materialize @cursor))) + (swap! cursor #(assoc (xdb/freeze! %) :v 2)) + (is (= {:nested {:v 2}} (xdb/materialize @db))) + (is (= {:nested {:v 1}} (xdb/materialize (xdb/deref-at db 1))))))) + (deftest freeze-array-list-test (testing "without freeze" (with-open [db (xdb/xit-db :memory)] diff --git a/test/xitdb/hashing_test.clj b/test/xitdb/hashing_test.clj new file mode 100644 index 0000000..3eccda8 --- /dev/null +++ b/test/xitdb/hashing_test.clj @@ -0,0 +1,20 @@ +(ns xitdb.hashing-test + (:require + [clojure.test :refer :all] + [xitdb.db :as xdb])) + +(deftest rejected-lookup-does-not-poison-subsequent-writes + (with-open [source (xdb/xit-db :memory) + other (xdb/xit-db :memory)] + (reset! source {}) + (reset! other {}) + (doseq [target [source other]] + (testing (if (identical? source target) "same database" "another database") + (is (thrown? IllegalArgumentException + (get @source (map identity [1])))) + (swap! target assoc :saved 42) + (is (= 42 (get @target :saved))) + (swap! target assoc :saved 43) + (is (= 43 (get @target :saved))) + (is (= 1 (count @target)) "updating the key must not create a duplicate entry") + (is (= {:saved 43} (xdb/materialize @target))))))) diff --git a/test/xitdb/multi_threaded_test.clj b/test/xitdb/multi_threaded_test.clj index e96527e..eb9630c 100644 --- a/test/xitdb/multi_threaded_test.clj +++ b/test/xitdb/multi_threaded_test.clj @@ -172,4 +172,113 @@ (println "thread 3:" (tu/materialize @db)) (catch Exception e (println "exception" e)))) - (tu/materialize @db))) \ No newline at end of file + (tu/materialize @db))) + +(defn- run-concurrently + "Runs `f` on `n` pool threads, returns after all finish." + [n f] + (let [pool (java.util.concurrent.Executors/newFixedThreadPool n) + latch (java.util.concurrent.CountDownLatch. n)] + (try + (dotimes [_ n] + (.submit pool ^Runnable (fn [] (try (f) (finally (.countDown latch)))))) + (when-not (.await latch 30 java.util.concurrent.TimeUnit/SECONDS) + (throw (ex-info "Timed out waiting for reader workers" {}))) + (finally + (.shutdownNow pool) + (is (.awaitTermination pool 5 java.util.concurrent.TimeUnit/SECONDS) + "reader workers terminate"))))) + +(deftest memory-db-concurrent-reads-are-consistent + (testing "many threads looking up keys in a :memory db all see the stored values" + (with-open [db (xdb/xit-db :memory)] + (reset! db (into {} (for [i (range 200)] [(str "key-" i) i]))) + (let [n-threads 8 + n-lookups 2000 + misses (atom 0) + wrong (atom 0) + errors (atom [])] + (run-concurrently + n-threads + (fn [] + (try + (let [v @db] + (dotimes [j n-lookups] + (let [i (mod j 200) + r (get v (str "key-" i) ::miss)] + (cond + (= r ::miss) (swap! misses inc) + (not= r i) (swap! wrong inc))))) + (catch Throwable t + (swap! errors conj (str (type t) ": " (.getMessage t))))))) + (is (= 0 @misses) "no lookup missed") + (is (= 0 @wrong) "no lookup returned another key's value") + (is (empty? @errors) "no reader threw"))))) + +(deftest memory-db-writer-is-unaffected-by-concurrent-readers + (testing "every swap! on a :memory db succeeds while readers run, and readers never miss" + (with-open [db (xdb/xit-db :memory)] + (reset! db {:counter 0 :data (into {} (for [i (range 100)] [(str "k" i) i]))}) + (let [n-readers 6 + n-writes 200 + running (atom true) + misses (atom 0) + errors (atom []) + readers (future + (run-concurrently + n-readers + (fn [] + (while (and @running (not (.isInterrupted (Thread/currentThread)))) + (try + (let [d (get @db :data)] + (dotimes [i 100] + (when (= ::miss (get d (str "k" i) ::miss)) + (swap! misses inc)))) + (catch Throwable t + (swap! errors conj (str (type t) ": " (.getMessage t)))))))))] + (try + (dotimes [_ n-writes] + (swap! db update :counter inc)) + (finally + (reset! running false) + (try + (is (not= ::timeout (deref readers 10000 ::timeout)) + "readers stop after the writer finishes or throws") + (finally + (future-cancel readers))))) + (is (= n-writes (get @db :counter)) "every write was committed") + (is (= 0 @misses) "no reader missed a key") + (is (empty? @errors) "no reader threw"))))) + +(defn- temp-db-file [] + (let [f (java.io.File/createTempFile "xitdb-mt" ".db")] + (.delete f) + (.deleteOnExit f) + (.getAbsolutePath f))) + +(deftest value-read-on-one-thread-is-safe-on-other-threads + (doseq [target [:memory (temp-db-file)]] + (testing (str "a value dereferenced once and shared with worker threads: " target) + (with-open [db (xdb/xit-db target)] + (reset! db (into {} (for [i (range 200)] [(str "key-" i) i]))) + (let [v @db + n-threads 8 + n-lookups 5000 + misses (atom 0) + wrong (atom 0) + errors (atom [])] + (run-concurrently + n-threads + (fn [] + (try + (dotimes [j n-lookups] + (let [i (mod j 200) + r (get v (str "key-" i) ::miss)] + (cond + (= r ::miss) (swap! misses inc) + (not= r i) (swap! wrong inc)))) + (catch Throwable t + (swap! errors conj (str (type t) ": " (.getMessage t))))))) + (is (= 0 @misses) "no lookup missed") + (is (= 0 @wrong) "no lookup returned another key's value") + (is (empty? @errors) "no reader threw")))))) diff --git a/test/xitdb/nested_value_test.clj b/test/xitdb/nested_value_test.clj new file mode 100644 index 0000000..3ba4052 --- /dev/null +++ b/test/xitdb/nested_value_test.clj @@ -0,0 +1,43 @@ +(ns xitdb.nested-value-test + "Values read from the database and then written back nested inside plain + Clojure collections must keep their on-disk type." + (:require + [clojure.test :refer :all] + [xitdb.db :as xdb])) + +(deftest sorted-map-nested-in-plain-map-stays-sorted + (with-open [db (xdb/xit-db :memory)] + (reset! db {:idx (sorted-map 3 :c 1 :a 2 :b)}) + (swap! db assoc :copy {:inner (get @db :idx)}) + (let [copy (get-in @db [:copy :inner])] + (is (sorted? copy)) + (is (= [[2 :b] [3 :c]] (subseq copy >= 2))) + (is (= {1 :a 2 :b 3 :c} (xdb/materialize copy)))))) + +(deftest sorted-map-nested-in-vector-stays-sorted + (with-open [db (xdb/xit-db :memory)] + (reset! db {:idx (sorted-map 3 :c 1 :a 2 :b)}) + (swap! db assoc :copies [(get @db :idx)]) + (let [copy (get-in @db [:copies 0])] + (is (sorted? copy)) + (is (= [[2 :b] [3 :c]] (subseq copy >= 2)))))) + +(deftest sorted-map-nested-in-list-stays-sorted + (with-open [db (xdb/xit-db :memory)] + (reset! db {:idx (sorted-map 3 :c 1 :a 2 :b)}) + (swap! db assoc :copies (list (get @db :idx))) + (let [copy (first (get @db :copies))] + (is (sorted? copy)) + (is (= [[2 :b] [3 :c]] (subseq copy >= 2)))))) + +(deftest sorted-set-nested-in-plain-collections-stays-sorted + (with-open [db (xdb/xit-db :memory)] + (reset! db {:tags (sorted-set "c" "a" "b")}) + (swap! db assoc :copies {:in-map (get @db :tags) + :in-vec [(get @db :tags)] + :in-list (list (get @db :tags))}) + (doseq [copy [(get-in @db [:copies :in-map]) + (get-in @db [:copies :in-vec 0]) + (first (get-in @db [:copies :in-list]))]] + (is (sorted? copy)) + (is (= ["b" "c"] (subseq copy >= "b")))))) diff --git a/test/xitdb/snapshot_test.clj b/test/xitdb/snapshot_test.clj new file mode 100644 index 0000000..303ed8b --- /dev/null +++ b/test/xitdb/snapshot_test.clj @@ -0,0 +1,31 @@ +(ns xitdb.snapshot-test + (:require + [clojure.test :refer :all] + [xitdb.db :as xdb] + [xitdb.snapshot :as snapshot])) + +(defn- temp-db-file [] + (let [f (java.io.File/createTempFile "xitdb-snapshot" ".db")] + (.delete f) + (.deleteOnExit f) + (.getAbsolutePath f))) + +(deftest snapshot-memory-db-copies-a-file-database-into-memory + (let [file (temp-db-file)] + (with-open [db (xdb/xit-db file)] + (reset! db {:users {"alice" {:age 30}} :tags #{:a :b} :order (sorted-map 2 :b 1 :a)})) + (testing "the whole database" + (with-open [mem (snapshot/snapshot-memory-db file [])] + (is (= {:users {"alice" {:age 30}} :tags #{:a :b} :order {1 :a 2 :b}} + (xdb/materialize @mem))) + (is (sorted? (get @mem :order))))) + (testing "a nested keypath" + (with-open [mem (snapshot/snapshot-memory-db file [:users "alice"])] + (is (= {:age 30} (xdb/materialize @mem))))))) + +(deftest snapshot-memory-db-copies-collection-keys + (let [file (temp-db-file)] + (with-open [db (xdb/xit-db file)] + (reset! db {[1 2] {"nested" [3]} {:k 1} :v})) + (with-open [mem (snapshot/snapshot-memory-db file [])] + (is (= {[1 2] {"nested" [3]} {:k 1} :v} (xdb/materialize @mem))))))