Eleven changes from @brettwooldridge, most of them found on a production system, and one H2 workaround found while testing them. Four are data-integrity fixes, three of which can end with a store that will not reopen or a query that quietly returns the wrong rows.
-
MVStore's chunk retention and versions-to-keep are left at H2's defaults (#1301, #1303)
MVStoreUtils.openOrCreateforcedsetRetentionTime(0)andsetVersionsToKeep(0)on every store since 2020. With both at 0, H2 may reuse a chunk's blocks while the chunk map it writes at close still lists that chunk, and the file then refuses to open at all - read-only or not - withMVStoreException: Double mark: 394/5 ... at FreeSpaceBitSet.markUsed. Only H2's recovery mode gets past it. (h2database/h2database#2752, #4083, both open.)- A 24-thread soak of a document workload with a close every 20 seconds reproduced it on every run at 0/0, with and without close-time compaction, on h2-mvstore 2.4.240 and on current H2 master - and on none of the runs with either setting at its H2 default (45 s / 5), including a 1 s retention window. H2's own javadoc notes the retention window is what lets readers finish traversing a map.
- New
MVStoreModuleBuilder.retentionTime(ms)andversionsToKeep(n). Both arenullby default, which leaves H2's values in place. Passing0restores the old behaviour, at the cost described above. - The file no longer shrinks the instant a chunk goes dead. Reclamation now waits out the retention window, as H2 intends.
close()still compacts synchronously.
-
A read no longer hands out anything the store holds (#1294)
- A stored document on MVStore is the live object in the page, and MVStore serializes pages on a background thread that any write can start through
tryCommit. Whatever a read hands back must therefore share no mutable state with it, or a caller's in-place edit is written straight into the store, bypasses the indexes, and can race the serialization into aConcurrentModificationExceptionand a store panic. - The cursor has cloned each document it yields since 4.x, but two gaps remained.
Document.clone()copied the top-level map and embedded documents only, so aList,Set,Map, array,byte[],DateorCalendarinside the copy was still the instance in the store andfound.get("tags").add(x)reached the page. AndNitriteCollection.getById()returned the stored instance itself. clone()is now a deep copy: containers and arrays are copied recursively, preserving the concrete collection class where it has a public no-arg constructor and the comparator of sorted sets and maps;Dates andCalendars are cloned; immutable values are shared, and so are values of a type the copy does not know, which the javadoc now states.getById()hands out a clone asfind()does, and returnsnullfor a missing id instead of passingnullthrough the processor chain.- The cost is a structural copy per result and per write, since the write path clones too. That buys the guarantee that no caller can reach into the store by accident.
- A stored document on MVStore is the live object in the page, and MVStore serializes pages on a background thread that any write can start through
-
The store catalog copies its stored name set instead of mutating it in place (#1296)
StoreCatalog.writeCollectionEntrywrapped the catalog document inMapMetaData, which took theSetstraight out of it and added the new name to that instance. On MVStore that instance is the one held in the catalog page. Creating collections in a burst interleaved with writes - which is what a data migration does - put the serializer'sHashSet.writeObjectand the catalog'sHashSet.addon the same set at the same time, ending inMVStoreException: Could not serialize {mapNames=[...]}andMVStore.panic, after which the store is closed and every later operation throws.- Observed on the first start after a Xodus-to-Nitrite migration that created eleven collections in forty milliseconds.
MapMetaDatanow copies, so a write always puts a new set and the instance a serializer may be reading is never touched - the ruleWriteOperations.updatealready followed by cloning a document before merging into it.
-
An index scan skips documents removed between the lookup and the fetch (#1302)
- An index scan is two steps that are not atomic under concurrent writes: the index yields the matching ids, then each document is fetched. A document removed in between came back as an
(id, null)row. With a residual filter that row reachedFilter.applyand threwNullPointerExceptionfrom the filter'sdocument.get(); without one the cursor handed the caller anullelement. The by-id fast path had the same window betweencontainsKeyandget. IndexedStreamnow prefetches and drops ids whose document is gone,FilteredStreamtreats a row without a document as a non-match, and the by-id path does oneget.skip()still walks ids without fetching them, so under a concurrent remove a page boundary can still count an id whose document is gone - which is the same indeterminacy the removal itself introduces.
- An index scan is two steps that are not atomic under concurrent writes: the index yields the matching ids, then each document is fetched. A document removed in between came back as an
-
clear()anddropIndex()reach every layout map an index occupies (#1295)IndexManager.close(),clearAll()anddropIndexDescriptor()acted only on the map name recorded inIndexMeta, which is the classic one. That already missed the composite map of a non-unique index: aftercollection.clear()its rows survived, and a query on that index returned the ids of the cleared documents alongside the new ones - two live documents, four results. It also brokeChangeIdField, whosecreateIndexfound the previous index map still populated and rebuilt over it.
-
The first concurrent read of an index after a restart no longer fails dropping its legacy map (#1309)
- The first read of an index still in a legacy layout migrates it and drops the legacy map, once per index instance.
ComparableIndexercreated those instances with an unsynchronized check-then-act, so threads arriving together could each get an instance of their own and each run the migration. On MVStore the second drop askedMVMap.getName()for a map that was already gone, gotnull, and failed withNullPointerExceptioninNitriteMVStore.removeMap; the same race also threw fromAttributes.setthroughNitriteMap.updateLastModifiedTime. - Observed on a production system on the first multi-threaded lookup after every restart, because every close before #1295 left an empty map under the legacy name for the next start to drop.
- The indexer and the MVStore map and R-tree registries now create their entries with
computeIfAbsent, so there is one index instance and one map wrapper per name.NitriteMVMapkeeps the name it was opened with and acts only when its compare-and-set wins, sodrop()andclose()run once;removeMapignores a null name and no longer creates an empty map only to remove it.
- The first read of an index still in a legacy layout migrates it and drops the legacy map, once per index instance.
-
Concurrent first reads of a map on an in-memory MVStore no longer fail inside H2 (#1311)
- H2's
ObjectDataTypepicks the delegate that compares serialized keys on first use, through an unsynchronized field, andSerializedObjectType.comparechecks delegates by identity. Threads making a map's first key comparison together could each install their own and fail withUnsupportedOperationException: Can not compare. The code is the same in h2 2.4.240 and 2.5.250. - Only an in-memory MVStore was exposed. A file-backed map is settled before any reader gets it, by reading its root page at open or by estimating the memory of a write. Through the public API, 32 threads making the first reads of a one-document indexed collection failed in 1 round of 5,000 on an in-memory MVStore, and in none of 10,000 rounds on a file-backed store, fresh or reopened. The shape that exposed it, the legacy-index migration of #1309 handing sixteen threads a map it had just created, failed about 1 round in 100.
NitriteMVStorenow settles eachObjectDataTypekey type once when it opens the map, on the one thread runningcomputeIfAbsent. Nothing is serialized. It can go once H2 fixes the type upstream.
- H2's
-
A unique index no longer rejects a document over a key that document already holds (#1295)
addNitriteIdstreated any existing id under the key as a violation, so it counted the writer's own id against it. Another document under the key is a violation; the same document again is not - which is what a unique index over an array field with a repeated element does, and what an index rebuild or a replayed write does.
-
One collection's long write no longer stalls
getCollectionfor every other collection (#1292)CollectionFactory.getCollectionheld one lock for the whole factory and, while holding it, calledisDropped()andisOpen()on the registered collection. Both take that collection's read lock. While a long write held a collection's write lock - an index rebuild, a largeremove(filter), an update on a big document - the one caller asking for that collection blocked inside the factory lock, and from then on everygetCollectioncall for every other collection queued behind it.- Observed on a production system: one thread rebuilding an index inside
update()for over three hours, and 349 other threads parked inCollectionFactory.getCollection, most of them wanting unrelated collections. - The registry is now read under the factory read lock, the usability check runs with no factory lock held, and the factory write lock is taken only to create or replace an entry. Callers of the busy collection still wait on it, as they should; callers of other collections no longer wait at all.
-
MVStoreModuleBuilder.pageSplitSizedefaults to 16 KB, not 16 bytes (#1293)- It is documented as "16 KB" and is passed straight to
MVStore.Builder.pageSplitSize, which takes bytes. Its value was16, the same literal used forcacheSize(megabytes) andcacheConcurrency(a count), so every leaf page split as soon as it held more than one entry. - Measured by rebuilding a 146 MB store of 46,926 entries at each setting: 93,781 pages at depth 13 with
16; 36,096 pages at depth 12 with MVStore's own persistent-store default of 16 KB; 13,327 pages at depth 6 with 64 KB, the largest value that survives H2's(cacheSize / cacheConcurrency) >> 4clamp. - Existing files are unaffected until their pages are rewritten; there is nothing to migrate.
- It is documented as "16 KB" and is passed straight to
-
An update that leaves an indexed value unchanged no longer rewrites the index (#1297)
DocumentIndexWriter.updateIndexEntrytreated an index as affected whenever the update document carried the indexed field, and then removed and rewrote the entry. An update that writes the whole document back - the common upsert shape - carries every indexed field with its old value, so every index was rewritten on every update for nothing. On the pre-4.4.0 list layout that was a copy of the whole per-key id list twice per index per write.- The old and new values of each affected index are now compared, deeply so that arrays and embedded values count as equal when their contents are, and the index is skipped when they match. A dirty index is not skipped: its rebuild still has to happen on the first write.
-
A unique index stores one id per key instead of a one-element list (#1295)
- A unique index kept the classic
value -> [id]layout: aCopyOnWriteArrayListper key that never held more than one element, allocated and copied on every write, plus a size check standing in for the uniqueness test. It now stores the id itself (value -> id) in a map of its own, named with a|uniquesuffix, and enforces uniqueness by comparing the stored id with the writer's. - An index still in the list layout is migrated the first time it is accessed and the legacy map dropped, the way the composite layout already migrates a non-unique index.
IndexMapexposes the single-id map to the scanner and the filters as one-element lists, so the read path is unchanged. The map has its own name because the RocksDB adapter decodes values by the declared type of the map they live in.
- A unique index kept the classic
-
Equality and range index scans stream instead of materializing every id (#1298)
NitriteIndexer.findByFilterreturns aLinkedHashSetof every matching id, sofind(k = v).firstOrNull()built the whole match set before handing back one row, and a bounded page paid for the entire result. On a non-unique index over a low-cardinality field that set is a large fraction of the collection on every lookup.- The composite layout already keeps its rows in key order, so the two plan shapes that map onto one bounded walk of it - an equality on the indexed field, and a two-sided range on it - are now served by a lazy iterator that starts at the first key inside the bounds and stops at the first key outside them. It honours the plan's reverse scan order, skips entries removed in an open transaction, and returns a document indexed under several keys once.
- New default methods
NitriteIndex.findNitriteIdStreamandNitriteIndexer.findByFilterStreamreturnnull, so every other index type, plugin indexer and plan shape keeps the materialized path unchanged. The covered-count shortcut that letssize()answer without fetching documents is kept by counting the streamed ids on demand.
-
Same-type numbers compare without going through
BigDecimal(#1299)Numbers.compareconverted both operands toBigDecimalon every call, two allocations per comparison, and every index key comparison lands there. On a production store with numeric index keys the conversion was the hottest frame in a multi-hour index rebuild.- Any two integral primitives now compare as
longs; two doubles or two floats compare as themselves once NaN and the infinities have taken the existing special-case path, with-0.0and0.0equal asBigDecimaltreats them; twoBigDecimals or twoBigIntegers use their owncompareTo. Every mixed pairing keeps the exactBigDecimalconversion, so cross-type equality such asInteger 1againstFloat 1.0fis unchanged.
-
MVStore no longer grows without bound under repeated updates (#1284)
- Updating a document leaves its old page obsolete, so a store written to far more often than it grows accumulates chunks that are mostly dead.
autoCompactFillRate(0)had disabled reclamation since #41, and the file climbed while the live data stayed small - a reported ~800MB around 100 live documents. - #41 was a cursor reading a chunk that compaction had already reclaimed (
Chunk 162 no longer exists), which is why compaction was turned off rather than fixed. Every iterator and cursor now registers an MVStore version for its lifetime, so chunks it is reading cannot be collected underneath it, and compaction is safe to enable again. Abandoned iterators are released by aCleanerand byclose()/drop()on the map. - New
MVStoreModuleBuilder.autoCompact, defaulting totrue. Set itfalsefor the previous behaviour. - Reclamation on close is guaranteed; reclamation while running is not.
close()compacts synchronously and always shrinks the file. MVStore's background compaction runs on a one-second tick and attempts its work under a try-lock, so a writer holding the store lock makes it skip that round: how much a long-running application gets depends on its idle time and on how many cores are available to schedule the thread on. An application that holds the database open for months should not rely on the background half alone. - Measured over 25 live documents and 100k updates: the file held at ~213KB against 299KB and climbing, and
close()left 57KB against 299KB. close(-1)is pinned to a single compaction thread for now, working around h2database/h2database#4286, where parallel compaction races on page references in 2.4.240. It is guarded by a private lock rather than theSystem.getProperties()monitor, which would have stalled everySystem.getPropertycall in the JVM for the length of a compaction.
- Updating a document leaves its old page obsolete, so a store written to far more often than it grows accumulates chunks that are mostly dead.
-
Index keys normalize to double only where the conversion is exact (#1282)
DBValuefolded every number to a double before using it as an index key. That is what makesInteger(5)andDouble(5.0)the same key (#178), which matters on stores that compare the encoded key rather than callingcompareTo- the RocksDB adapter is one.- A double stops stepping by one above 2^53. Around 8.7e17 the nearest doubles are 128 apart, so ids closer than that folded onto one key: a unique index rejected an id it had never seen, and a non-unique lookup returned rows belonging to a different id. Snowflake ids, TSIDs and ULID-style identifiers all live there.
- The fold now applies only where the value survives it.
Integer,Short,ByteandFloatalways do;Longis checked by casting the double back, guarded by a range test, becauseLong.MAX_VALUE's double rounds up to 2^63 and the cast back saturates ontoMAX_VALUEagain.BigIntegerandBigDecimalare compared against the exact value of the double they produce. - Cross-type equality is unchanged for every value a double holds, which is the range #178 is about.
- Behaviour change: a
Longand aBigIntegerholding the same value above 2^53 are no longer the same key on a byte-comparing store. They only matched before because both had been rounded onto the same double - the same rounding that would equally have matched a different id. An index built by an earlier version over such values holds the folded keys, those entries were already colliding, and it should be rebuilt.
- Paging coverage for the plan shapes a source-level skip has to decline - an
orplan, a collection with removals, and an indexed filter ordered by that same index. From #1283, which proposed pushing the offset down per plan; the skip had already landed by capability in 5.1.0/5.2.0, but those shapes are worth holding because getting them wrong returns the wrong rows rather than merely slowly.
-
nitrite-bridge— inspect a running Nitrite database from a desktop client. The Nitrite adapter for thedbinspectwire protocol: collections and handed-in repositories as browsable stores, schema inferred by sampling documents and always flagged as inferred, paging overFindOptionsskip/limit/orderBy, the JSON filter DSL, and watch over Nitrite's collection subscription. The engine-neutral core it plugs into isorg.dizitart:dbinspect-bridge, which has no database in its dependency tree at all.- Row editing is behind
allowWrite, and whole-storesnapshotbehindallowSnapshot— bothfalseunless the embedding application asks, and absent from the reported capabilities while they are. A row is addressed by_id; an update is partial;changes: 0means the row was not there._idinside an update'svaluesis refused, because Nitrite merges an update document and it would rewrite the identity of the row it just matched. regexis off unlessallowRegexis set, and length-capped with a nested-quantifier check when it is on.java.util.regexbacktracks and a match cannot be interrupted, so the default is the mitigation that matters.- Every client-supplied store name is resolved against the set the adapter reported.
Nitrite.getCollectioncreates a collection that does not exist, so an unchecked name would let a paired client make one; writes go through the same allow-list. - The module is deliberately absent from the reactor
<modules>:dbinspect-bridgeis not on Maven Central until the dbinspect release, so a build of a clean checkout would fail on it. Build it on its own, withdbinspect-bridgeinstalled locally.
- Row editing is behind
-
The bridge can open a transaction (
docs/PROTOCOL.md§3.1). A transaction is a second adapter over the same database rather than a mode on the first, so one connection's uncommitted documents can never reach another connection's reads. Nitrite's transaction lives above the storage engine — aTransactionStorebuffers the writes and a journal replays them on commit — so this works identically on MVStore, RocksDB and in memory.capabilities.transactionsfollowsallowWrite: that flag is the permission, this reports what the engine can undo. The transactional twin reports itfalse, because Nitrite does not nest one.listStorescounts through the transaction, since a total that left out the rows the person just staged is not read-your-own-writes.
- Paging a collection no longer costs every row before the page -
find(filter, skipBy(n).limit(m))now advances the storage iterator without decoding what it passes over.BoundedStreamskipped by callingnext()on the iterator beneath it, and that iterator is below the document layer: it deserialised every skipped document in order to throw it away. Page latency was therefore linear in the page number - about 9 µs per skipped row on both persistent stores - so a 50 000-row collection paged at 200 rows took 3 ms for the first page and 350 ms for the two-hundredth. It is an ordinary browse, not an edge case.- New
SkippableIterator, implemented by the storage-level iterators that can do better and by nothing else.BoundedStreamuses it when it is there and falls back to the loop when it is not, so a stream that has to look at a document to know whether to skip it - a collection-scan filter, a blocking sort - is unaffected and still correct. - MVStore descends the tree by index. An MVStore page records how many entries sit beneath it, so
MVMap.getKey(n)is a O(log n) descent rather than a scan; the skip seeks with it and continues from a cursor. Measured over 50 000 rows, 200 to a page: p95 583.9 ms → 5.8 ms, and the first and last pages now cost the same 0.1 ms. - RocksDB steps the native iterator. There is no seek-by-index to use, but the decode is what a skipped row actually cost; advancing past one is now a memcmp inside the block the iterator is already on. p95 3.8 ms over the same 50 000 rows.
- An index scan skips its own id set the same way, without the map lookup and decode
next()would have paid for each row.
- Fix the index dirty marker not being persisted (#1281)
markDirty()flippedisDirtyon theIndexMetathatget()returned and never put it back, so nothing told the store the entry had changed and the new value was not guaranteed to be written. BothbeginIndexing()andendIndexing()go through it, so the marker was unreliable in both directions: an index left half-built by a crash could come back reading as clean, and a completed one could stay marked dirty and be rebuilt on the first write after every open.
- A sorted, limited
findno longer fetches the whole collection when the sort field is indexedfind(ALL, orderBy("createdAt", Descending).limit(20))asked for 20 rows and cost what draining every stored document costs.SortedDocumentStreamcollects the entire result set beforeBoundedStreamgets to drop 99% of it — and the cost is the decode, not the comparison, so it scales with document size as well as count. An index on the sort field bought nothing: the index was only ever used to filter, never to order, and page 50 cost exactly what page 1 cost because the work finished before the skip applied.- When the query has no filter, one sort field, a limit, and a simple unique or non-unique index on exactly that field, the sort keys are now read from that index — which already stores them — and only the documents actually returned are fetched. Measured over 2000 rows each carrying a 150-element list, a
limit(20)page went from ~125 ms to ~1 ms, and stopped growing with the collection. - The index is used only when it holds exactly one entry per stored document. A multi-valued field is indexed once per element, which is detected by a duplicate-id check and an entry-count check, and falls back to the blocking sort. Ordering — including where nulls sort and how ties break — is identical either way: the same comparator runs over keys taken from the index instead of from the documents.
- Where documents are small and cheap to read, the index walk replaces a decode that was nearly free, so a sorted page over lean rows can cost a few hundred microseconds more than before. The trade is deliberate: the loss is bounded and sub-millisecond, the win grows without bound with document size.
- New API, all additive:
FindPlan.getSortIndexDescriptor(),NitriteIndex.readSortKeys(long)andNitriteIndexer.readSortKeys(IndexDescriptor, NitriteConfig, long)(bothdefault-implemented to returnnull, so existing indexer plugins are unaffected), andDocumentSorter.compareValues(Object, Object, Collator).
- Removed the
distinctfind option -FindOptions.withDistinct()(both overloads),FindOptions.distinct()andFindPlan.distinct. It had no effect on the result set. A find never returns the same document twice, and theorsub-plan union has always deduplicated unconditionally, so the flag was written by the planner and never read. Callers passingFindOptions.withDistinct()can drop it with no change in results.
- Add an
existsfilter -where("field").exists()matches the documents which have the field, irrespective of its value;where("field").exists().not()matches those which do not.- A field explicitly set to
nullis present and matches. This is the case no existing filter could express:eq(null)andnotEq(null)cannot tell a missing field apart from one holdingnull, so "has this document been given a value for this field at all" was not answerable. - The filter deliberately does not extend
ComparableFilterand so always runs as a collection scan. A missing field and a field holdingnullare stored under the same null key in an index, so an index scan could not tell them apart and would disagree with a collection scan. - Embedded fields are addressed by their dotted path (
where("address.city").exists()), the same wayDocument.containsFieldresolves them.
- A field explicitly set to
- Fix JVM crash (SIGSEGV) in the RocksDB backend when a query pages past the end of a collection
EntrySet,KeySetandValueSetclose the nativeRocksIteratorthe first timehasNext()answersfalse, buthasNext()is idempotent by contract and a second call reachedisValid()on the freed handle.BoundedStream's skip loop exhausts the iterator and then the caller asks once more, so anyfind(..., skipBy(n))whose skip exceeds the number of remaining records hit it — an ordinary paging request, not an edge case. The existingcatch (AssertionError)only covered the case where assertions are enabled (-ea), which is how the tests ran but not how an application runs: without assertions the same call is aSIGSEGVinJava_org_rocksdb_RocksIterator_isValid0Jnithat takes the whole process down.hasNext()now returnsfalsewhen the iterator no longer owns its handle, so it never touches a closed iterator and stays idempotent.
- Fix
field.eq(x)/field.in(..)on an array (list) field silently matching nothing when the filter runs as a collection scan- Array membership is matched element-wise on the index path (arrays are indexed per element) but was matched by whole-value equality (
deepEquals) on the collection-scan path, so a query's results depended on whether an index existed or was chosen by the planner. Combined with the 4.4.2 planner change (#1266) — which correctly relegates the non-winning-index filter to a collection scan — an AND of an indexed arrayeqand a bounded range on a second indexed field left the arrayeqrunning as a collection scan, where it matched no documents. EqualsFilterandInFilternow match an array/Iterablefield by element containment on the collection-scan path, mirroringapplyOnIndex, so results are the same regardless of index presence.
- Array membership is matched element-wise on the index path (arrays are indexed per element) but was matched by whole-value equality (
- Fix
ClassCastExceptionwhen an AND filter combines an equality (or other comparable) filter on one indexed field with a bounded range on a second, differently-typed indexed field #1266- The query planner picked the best-matching index per field independently while scanning candidate indexes, but accumulated filters from every candidate it visited into one shared set instead of keeping only the winning index's filters. The resulting index scan filter set could carry filters from two unrelated indexes (e.g. a
String-valuedeqfilter alongside aLong-valued range pair), which were then all applied against whichever single index the planner picked, comparing a value of the wrong type against that index's keys. - The planner now selects a single best-matching index descriptor and only keeps that index's own filters for the index scan; filters on any other field fall back to a post-filter (collection scan) step as before.
- The query planner picked the best-matching index per field independently while scanning candidate indexes, but accumulated filters from every candidate it visited into one shared set instead of keeping only the winning index's filters. The resulting index scan filter set could carry filters from two unrelated indexes (e.g. a
- Fix unfiltered Java deserialization in the legacy v1 database migration path (CWE-502, GHSA-9297-g93h-86gg, CVSS 9.8)
- Opening a file-based store runs
MVStoreUtils.testForMigration(), which for a legacy v1-format file deserialized stored values throughObjectInputStream.readObject()with no class restriction. AnySerializableclass on the embedding application's classpath could be instantiated, so a suitable gadget chain (e.g. from commons-collections) made a malicious.dbfile a remote-code-execution vector. - The v1-compat deserializer now enforces a JEP 290 allowlist filter that only permits Nitrite's own types and standard JDK types; any other class is rejected before its
readObject/readResolvecallbacks can run. Applications that open Nitrite database files from untrusted sources (e.g. "import"/"restore backup" features) should upgrade.
- Opening a file-based store runs
- Fix intermittent
ConcurrentModificationExceptionand spuriousUniqueConstraintExceptionfrom unique and full-text indexes on the MVStore backend (regression introduced by the #1260 index rework in 4.4.0)- Unique and full-text indexes still store a
List<NitriteId>value per key. 4.4.0 switched that list fromCopyOnWriteArrayListto a plainArrayList, which is mutated in place after being written to the map. MVStore serializes dirty page values on a background thread, so that in-place mutation races with the serializer and threwConcurrentModificationException(and could corrupt the id list, surfacing later as a false unique-key violation) — even under single-threaded use. - These index value lists are
CopyOnWriteArrayListagain, so each mutation swaps the backing array atomically and the background serializer always sees a stable snapshot. The composite-key layout for non-unique indexes (the actual #1260 optimization) is unchanged.
- Unique and full-text indexes still store a
- Non-unique single-field indexes now use a composite-key on-disk layout (one
(value, id)row per entry) instead of a single growing id list per value #1260- The public API is unchanged. Existing databases are upgraded automatically: a legacy array-format non-unique index is rebuilt into the new layout the first time it is opened, and the old index map is dropped.
- The storage format change is forward-only — once a database has been opened by this version, it can no longer be read by an earlier version of Nitrite. Back up before upgrading if you may need to roll back.
- Fixed performance degradation when inserting thousands of documents that share the same non-unique index key #1260
- The old layout re-wrote (and, on persistent stores, re-serialized) an ever-growing id list on every insert, making bulk inserts O(n²). The composite-key layout makes each insert and removal an O(log n) point operation across all backends (in-memory, MVStore and RocksDB).
- RocksDB orders keys by their serialized bytes, so the composite key uses an order-preserving encoding (correct ranges over negative numbers, variable-length strings, dates and booleans).
- Made
infilter index scans look up each value directly instead of scanning every index entry, soinqueries on large indexed collections are now as fast aseqinstead of degrading to a full index walk #1258
- Fix
DocumentSorterviolating theComparatorcontract when two documents both have a null sort key, which caused intermittentIllegalArgumentException: Comparison method violates its general contract!fromorderByon fields with multiple null values #1261 - Fix indexed
lt/ltefilters returning an empty result when the indexed field contains any null value; the forward index scan now starts from the first non-null key #1262 - Fix descending indexed
lt/ltefilters leaking null-valued documents into the result on reopened persistent stores (MVStore and RocksDB); stored null index keys are now normalized to theDBNullsentinel in every index navigation - Fix the RocksDB adapter failing to round-trip the null index key through Kryo, and decoding scanned keys with the wrong type when a range scan probe and the stored key have different classes
- Fix
in/notInfilters on the_idfield not matching legacy String ids written by pre-4.4 databases, whileeq/getByIdon the same rows matched #1263 - Fix
eq/notEqfilters on the_idfield for legacy String ids:eq('_id', "3")oreq('_id', 3)threwClassCastExceptionin the byId fast path, andnotEq/negatedeqfailed to exclude legacy rows during collection scans
- Support for interface entity types with EntityDecorator #1183
- Fixed NearFilter to support geodesic distance for geographic coordinates #1185
- Added GeoPoint class for explicit geographic coordinate support
- Created GeoNearFilter for geodesic distance queries
- Implements two-pass query execution to eliminate false positives from bounding box approximation
- Added comprehensive test suite for geographic coordinate support
- Upgraded Jackson to version 3 #1221
- Build now targets JUnit 6 and requires Java 17 to build/test, while keeping Java 11 bytecode compatibility for the published artifacts #1179
- Optimized index scans for multi-bound range queries (e.g.
gtcombined withlton the same field) - Added covered-count optimization so
size()/count()is answered directly from index scans and plain full scans without fetching and deserializing every matching document
- Fix
in/notInfilters not using the index while querying a collection #1258 - Fix record ID match for legacy string keys in OR clause #1246
- Fix OR filters returning duplicate documents when using multiple indexes #1184
- Fix inconsistent numeric filtering across types with indexes #1175
- Fix elemMatch queries to use array field indexes #1174
- Fix native-image build: initialize JUnit MethodSegmentResolver at runtime #1189
- Bumped production and development dependencies across the project (grouped Dependabot updates)
- Fix for small safety/cleanup in Nitrite interface (map lookups, closed check, name trim) #1161
- Fix for updating to 4.3.1 causes existing databases to not open correctly #1162
- GraalVM support for nitrite-mvstore-adapter #995
- Event subscription api changes
- Fix for
Document.getFields()not returning iterable fields - Fix for failing tests on systems with non-ENGLISH locale #994
- Fix for NPE in
DefaultTransactionalRepository#1032 - Fix for JPMS issue #1035
- Fix for RocksDB adapter issue #1093
- Nitrite now supports JPMS. It is now modular and can be used in Java 9 or above.
- Version upgrade for several dependencies
- Repository type validation can be disabled in
NitriteBuilderas a fix for #966
- Fix for #935
- Fix for #948
- Fix for #961
- Fix for #966
- Fix for #977
- Fix for #990
- Fix for #917
- Fix for #916
- Fix for #911
- Version upgrade for several dependencies
- Fix for #901
- Fix for #902
- Version upgrade for several dependencies
- Nitrite API has been re-written from ground up. It is now more stable and performant. But there are breaking changes. Please read the guide for more details.
- Nitrite now requires Java 11 or above.
- Nitrite is now modular. It has been now divided into several modules. You can use only the modules you need.
- Modular storage adapters are now available. You can use only the storage adapter you need.
- MVStore version upgraded to 2.2.224
- RocksDB has been introduced as a new storage adapter.
- Nitrite now supports transaction.
- Nitrite now supports schema migration.
- Nitrite now supports spatial indexing and search
- Nitrite now supports compound indexes.
- Nitrite now support import/export of data in JSON format.
- Build system has been migrated to Maven.
- Nitrite DataGate has been deprecated.
- Nitrite Explorer has been deprecated.
- Emergency fix for #697
- Random crashes with exception "Fatal Exception: java.lang.IllegalStateException: Chunk 55267 not found" #386
- Null pointer on updating full text index #366
- Breton list is actually Brazilian Portuguese #251
- Fix for NoClassDefFoundError in isObjectStore #220
- Fix for Full text index is not updated field update #222
- Fix for Deadlock in latest 3.4.0 #212
- Fix for UniqueConstraintException when upserting #193
- Fix for several NPEs under certain edge case conditions #203
- Fix for Off-Heap store memory utilization issues #211
- Upgrade MVStore version to 1.4.200
- Add Support for Off-Heap Memory #160
- Offer close und update methods for TextIndexingService #176
- Allow to access collection of IDs from find result #165
- Sorting with accents #144
- Null pointer exception when querying data #185
- Documentation : support for querying embedded objects #157
- Documentation: minSdkVersion should be 19 #167
- Index not removed for fulltext-indexed field when using a third-party TextIndexingService #174
- Performance enhancements for InFilter() #173
- Filtering on indexed fields with multiple Number only retrieves same type as given Comparable #178
- Unique constraints apparently not checked when updating document #151
- Upgrade MVStore version to 1.4.198 #134
- Improve
Mappableperformance using constructor cache #133 - Make
ObjectRepositoryandNitriteCollectionimplementsCloseable#108
- Database file remains locked after failed connection #116
- Exception when removing a document on a text indexed collection #114
- NitriteBuilder openOrCreate returns silently null #112
- Keyed
ObjectRepositorysupport #78 - Podam version upgraded to resolve missing JAX-WS dependency in Java 9 #90
- MVStore upgraded to latest release #69
- Introduced a utility method to register jackson modules in
NitriteBuilder#94 - Null order support during sort #98
@InheritIndicesnow works for fields with any modifier #101
- Fixed documentation for MapperFacade #100
- Added documentation for @NitriteId annotation #102
- Changes to text index not saved correctly #105
- Closing the database recreates dropped collections #106
- Recover should return success/failure #89
- Reopening issue #72, with variation of failing scenario still broken in 3.0.1 #93
- Jackson modules are auto discoverable #68
- Refactoring of NitriteMapper #74
- Make runtime shutdown hook optional #84
- Fix for order by using a nullable columns #72
- Fix for DataGate server for Windows #71
- Intermittent NPE in remove #76
- Fix for NPE in indexing #77
- Documentation for POJO annotation #81
KNO2JacksonMapperis now extendable- Support for
NitriteIdas id field of an object - Object's property can be updated with null
- Support for
java.time& it's backport - Change in update operation behavior (breaking changes)
- ConcurrentModificationException in
NitriteEventBus- #52 - Duplicate
@Idin concurrent modification - #55 - Fixed a race condition while updating the index entries - #58
- Fix for sort operation - #62
- Version upgraded for several dependencies - #64
- Kotlin version upgrade to 1.2.20
- Data import export extension added in potassium-nitrite
- Fixes concurrency problem while compacting database - #41
- Lucene example fixed for update and lucene version upgraded - #44
- Fixed collection registry and repository registry - #42
- Readme updated with potassium-nitrite - #49
- Introduced potassium-nitrite - kotlin extension library for nitrite
- Multi-language text tokenizer support - #36
- Cursor join - #33
- Inherit
@Id,@Indexannotations from super class - #37 - Default executor behaves like
CachedThreadPoolexecutor - #32
- Put a check on object if it is serializable - #31
- Fix for SOE - #29
- Fix for sync issue - #25
- Detailed log added in
JacksonMapper
- Introduced
Mappableinterface to speed up pojo to document conversion in Android - #18
NitriteMapperandJacksonMappermoved from packageorg.dizitart.no2.internalstoorg.dizitart.no2.mapper
- Fix for
ObjectFilters.ALL- #14 - Fix for
dropIndex()- #22 - Documentation added - #12, #20
- Minor bug fixes for DataGate server - #6 , #7 , #8
- File parameter added while opening a database - #5
- Documentation updated - #3 , #8
- Initial release